diff --git a/.gitignore b/.gitignore index bfb8171..acfa5f0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ .vscode/ jsconfig.json +# Coding agents +.claude/ + # Xcode # *.pbxuser diff --git a/CHANGELOG.md b/CHANGELOG.md index 617bb0e..d41b661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Breaking changes - **Android**: rejection codes now reflect the failure. `presentCaptiveSigning` and `presentCaptiveSigningWithUrl` previously rejected every error as `signing_failed`; they now surface `not_initialized`, `not_logged_in`, `login_failed` or `signing_failed`, matching the codes the error table has always documented. Callers matching on `error.code === 'signing_failed'` to detect a missing `initialize()` or `loginWithAccessToken()` need to match the specific code instead. +- **iOS**: rejection codes now match the documented table and the Android module. Expo derives a code from the exception class name when none is set, so `not_initialized` reached JS as `ERR_NOT_INITIALIZED`, `signing_failed` as `ERR_SIGNING_FAILED`, and so on for every code the README has always listed. Callers matching on the `ERR_`-prefixed variants need to match the documented code instead. +- **iOS**: `presentCaptiveSigning` and `presentCaptiveSigningWithUrl` forward the failure's own code rather than rejecting everything as `signing_failed`. A failure to find a presenting view controller now rejects and emits `presentation_failed`. The rejection message is the underlying error text on its own, where it previously carried a `DocuSign signing failed:` prefix. - **Android**: one `onSigningError` event per failure instead of two. The module emitted an event alongside the manager's own, which also flattened `recipient_signing_failed` into `signing_failed`. Listeners that deduplicated by hand can drop that workaround; listeners that counted events will see the count halve. ### New features @@ -14,6 +16,7 @@ ### Fixes +- **iOS**: reject a blank or non-`https` `signingUrl` before presenting. `DSMEnvelopesManager.presentCaptiveSigning` validates nothing and presents unconditionally, so a malformed URL rendered an empty signing controller whose completion never fired and left the promise unsettled. `signingUrl` defaults to `""` when JS omits it, so this was reachable without a malformed URL at all. Brings iOS to parity with the Android guard below. - **Android**: reject a blank or non-`https` `signingUrl` before launching. The SDK's URL overload validates nothing and calls `startActivity` unconditionally, so a malformed URL opened an empty signing activity and left the promise unsettled. - **Android**: `presentCaptiveSigning` now clears `currentEnvelopeId` when the launch itself throws, matching the URL path. diff --git a/README.md b/README.md index 04accd3..5c89334 100644 --- a/README.md +++ b/README.md @@ -803,6 +803,9 @@ The module rejects promises with coded exceptions you can inspect at the call si | `signing_failed` | SDK failed to present or complete signing | Check envelope ID, recipient info, SDK login state | | `not_initialized` | `initialize()` was not called first | Call `initialize()` before any other method | | `not_logged_in` | `loginWithAccessToken()` was not called first | Call `loginWithAccessToken()` before `presentCaptiveSigning()` | +| `presentation_failed` | No view controller available to present from (iOS) | Present from a mounted screen, not during a navigation transition | + +Both platforms emit these codes verbatim. Do not match on `ERR_`-prefixed variants. Example: diff --git a/ios/DocuSignError.swift b/ios/DocuSignError.swift index b1390fa..bf4ced3 100644 --- a/ios/DocuSignError.swift +++ b/ios/DocuSignError.swift @@ -1,30 +1,54 @@ import ExpoModulesCore +// Codes are given explicitly rather than inferred. Expo derives a code from the class name when +// none is set, which would surface NotInitializedException to JS as ERR_NOT_INITIALIZED, not the +// not_initialized documented in the README error table and emitted by the Android module. + internal class NotInitializedException: Exception { + override var code: String { + "not_initialized" + } + override var reason: String { "DocuSign SDK has not been initialized. Call initialize() first." } } internal class NotLoggedInException: Exception { + override var code: String { + "not_logged_in" + } + override var reason: String { "DocuSign SDK is not logged in. Call loginWithAccessToken() first." } } internal class PresentationException: GenericException { + override var code: String { + "presentation_failed" + } + override var reason: String { "Failed to present DocuSign signing UI: \(param)" } } internal class SigningFailedException: GenericException { + override var code: String { + "signing_failed" + } + override var reason: String { "DocuSign signing failed: \(param)" } } internal class LoginFailedException: GenericException { + override var code: String { + "login_failed" + } + override var reason: String { "DocuSign login failed: \(param)" } diff --git a/ios/DocuSignManager.swift b/ios/DocuSignManager.swift index 7d00dc0..206b245 100644 --- a/ios/DocuSignManager.swift +++ b/ios/DocuSignManager.swift @@ -672,6 +672,19 @@ internal final class DocuSignManager: NSObject { } } + /// Guards the URL handed to the SDK's URL overload. + /// + /// `DSMEnvelopesManager.presentCaptiveSigning(withPresenting:signingUrl:...)` validates nothing + /// and presents unconditionally, so a blank or non-https URL renders an empty signing controller + /// whose completion never fires and leaves the promise unsettled. `CaptiveSigningUrlRecord` + /// defaults `signingUrl` to "", so an omitted field reaches here as a blank string. + private static func isHttpsUrl(_ url: String) -> Bool { + guard let components = URLComponents(string: url) else { + return false + } + return components.scheme?.lowercased() == "https" && !(components.host ?? "").isEmpty + } + /// Presents captive signing from a pre-minted DocuSign recipient-view URL. /// /// The signing URL itself encodes recipient identity via a short-lived token, @@ -687,6 +700,12 @@ internal final class DocuSignManager: NSObject { throw NotInitializedException() } + // Ahead of the pendingCompletion claim on purpose: a rejected URL must not occupy the slot, or + // a later valid call would be refused as "already in progress". + guard Self.isHttpsUrl(signingUrl) else { + throw SigningFailedException("Signing URL must be a valid HTTPS URL") + } + var alreadyInFlight = false stateQueue.sync { if pendingCompletion != nil { diff --git a/ios/DocuSignModule.swift b/ios/DocuSignModule.swift index d358891..1602970 100644 --- a/ios/DocuSignModule.swift +++ b/ios/DocuSignModule.swift @@ -109,12 +109,17 @@ public class DocuSignModule: Module { "errorMessage": outcome.errorMessage as Any ]) case .failure(let error): + // Forward the failure's own code. Hard-coding signing_failed flattened + // presentation_failed and hid which stage failed. promise.reject(error) is not the + // alternative here: it wraps anything that is not an Exception, so a raw SDK NSError + // would surface as ERR_UNEXPECTED. + let code = (error as? CodedError)?.code ?? "signing_failed" self.sendEvent("onSigningError", [ "envelopeId": params.envelopeId, - "errorCode": "signing_failed", + "errorCode": code, "errorMessage": error.localizedDescription ]) - promise.reject(SigningFailedException(error.localizedDescription)) + promise.reject(code, error.localizedDescription) } } } catch { @@ -138,12 +143,17 @@ public class DocuSignModule: Module { "errorMessage": outcome.errorMessage as Any ]) case .failure(let error): + // Forward the failure's own code. Hard-coding signing_failed flattened + // presentation_failed and hid which stage failed. promise.reject(error) is not the + // alternative here: it wraps anything that is not an Exception, so a raw SDK NSError + // would surface as ERR_UNEXPECTED. + let code = (error as? CodedError)?.code ?? "signing_failed" self.sendEvent("onSigningError", [ "envelopeId": params.envelopeId, - "errorCode": "signing_failed", + "errorCode": code, "errorMessage": error.localizedDescription ]) - promise.reject(SigningFailedException(error.localizedDescription)) + promise.reject(code, error.localizedDescription) } } } catch {