diff --git a/package-lock.json b/package-lock.json index f57a9e7..90d19be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.7.0", "dependencies": { "@deltablot/dropzone": "^7.4.3", - "@e4a/pg-js": "^2.1.5", + "@e4a/pg-js": "^2.2.0", "@iconify/svelte": "^5.2.1", "@privacybydesign/yivi-css": "^1.0.1", "@sentry/browser": "^10.20.0", @@ -326,9 +326,9 @@ } }, "node_modules/@e4a/pg-js": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@e4a/pg-js/-/pg-js-2.1.5.tgz", - "integrity": "sha512-glhBfaExCs63PyEgSqBQAgmqcPDcIRWEZuxDDUw60MfLOYqgXw/feCEcDgRTkFq28a7sU+Q1PBWIUwpAR7WHDA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@e4a/pg-js/-/pg-js-2.2.0.tgz", + "integrity": "sha512-DUg/aEwSG+kMaRWzEUCJNuaYqcWIh8wgSBIxDD4C1NaKmIN57Bg6j3apNAPhuEC5VmHHKj/hCRvid4nN3k0Utw==", "license": "MIT", "dependencies": { "@e4a/pg-wasm": "^0.6.1", diff --git a/package.json b/package.json index ece47fe..c78b25b 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ }, "dependencies": { "@deltablot/dropzone": "^7.4.3", - "@e4a/pg-js": "^2.1.5", + "@e4a/pg-js": "^2.2.0", "@iconify/svelte": "^5.2.1", "@privacybydesign/yivi-css": "^1.0.1", "@sentry/browser": "^10.20.0", diff --git a/src/lib/components/filesharing/SendButton.svelte b/src/lib/components/filesharing/SendButton.svelte index 20f2883..3d5fffe 100644 --- a/src/lib/components/filesharing/SendButton.svelte +++ b/src/lib/components/filesharing/SendButton.svelte @@ -12,6 +12,8 @@ NetworkError, UploadSessionExpiredError, YiviSessionError, + type PreparedSign, + type SigningKeys, } from '@e4a/pg-js' import { tick } from 'svelte' @@ -49,7 +51,23 @@ let scanQrParts = $derived($_('filesharing.sign.scanQR').split('Yivi')) let isMobileDevice = isMobile() - let mobilePopupMode: 'none' | 'direct' | 'qr' = $state('none') + let mobilePopupMode: 'none' | 'direct' | 'qr' | 'onetap' = $state('none') + + // --- iOS one-tap pre-warm --------------------------------------------- + // On iOS a Yivi Universal Link only opens the app inside a genuine user + // gesture. If we start the signing session on tap, the deep-link URL + // doesn't exist yet, so the tap can't open the app. Instead we pre-warm + // the session as soon as the compose form is valid (see the $effect + // below): pg.prepareSign() runs the disclosure ahead of time and hands us + // the app URL, which we put on the send button as a real . One tap + // then opens the app; the resolved signing keys are reused for encryption. + // Best-effort — until the URL is ready (or on desktop) we fall back to the + // two-tap button/QR flow. + let preparedSign: PreparedSign | null = null + let yiviAppUrl: string | null = $state(null) + // True while an actual send is in flight, so the pre-warm effect doesn't + // start a competing session or tear down the one we're consuming. + let sending = $state(false) let showValidationModal = $state(false) let validationErrors: string[] = $state([]) let limitExceededMessage: string | null = $state(null) @@ -88,6 +106,82 @@ return true }) + // Start (or restart) a pre-warmed Yivi signing session for the one-tap + // flow. Renders the Yivi widget into a hidden host purely to drive the + // session; the user never sees it — they tap our own send anchor instead. + // Synchronous up to assigning `preparedSign` so a re-entrant effect run + // can't start a second competing session. The hidden host is always in the + // DOM on mobile (rendered at mount), and effects run post-mount, so it's + // present here; if not, we simply skip and retry on the next trigger. + function startPrewarm(): void { + if (preparedSign || !browser) return + if (!document.querySelector('#prewarm-yivi')) return + const handle = pg.prepareSign({ + element: '#prewarm-yivi', + attributes: SIGN_ATTRIBUTES, + includeSender: true, + }) + preparedSign = handle + handle.mobileUrl + .then((url) => { + if (preparedSign === handle) yiviAppUrl = url + }) + .catch(() => { + // Session failed before showing the app button; the keys + // handler below resets us to the fallback flow. + }) + handle.keys.catch(() => { + // Session ended without a send (idle timeout, or cancelled in the + // app before tapping). Drop back to the two-tap fallback; a fresh + // pre-warm starts on the next form-valid / focus transition. + if (preparedSign === handle && !sending) { + preparedSign = null + yiviAppUrl = null + } + }) + } + + function cancelPrewarm(): void { + preparedSign?.cancel() + preparedSign = null + yiviAppUrl = null + } + + // Drive the pre-warm lifecycle from compose state: warm while the form is + // valid and we're still composing; tear down otherwise (unless a send is + // consuming the session). + $effect(() => { + if (!browser || !isMobileDevice) return + const composing = + encryptState.encryptionState === EncryptionState.FileSelection + if (canEncrypt && composing && !preparedSign) { + startPrewarm() + } else if ((!canEncrypt || !composing) && preparedSign && !sending) { + cancelPrewarm() + } + }) + + // A pre-warmed session has a TTL. If the user backgrounds the tab and + // returns after it lapsed, re-warm so the one-tap URL is fresh. Reads + // canEncrypt inside the handler (not at setup) so this listener is + // installed once. + $effect(() => { + if (!browser || !isMobileDevice) return + const onFocus = () => { + if ( + canEncrypt && + encryptState.encryptionState === + EncryptionState.FileSelection && + !preparedSign && + !sending + ) { + startPrewarm() + } + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }) + function getValidationErrors(): string[] { const errors: string[] = [] if (encryptState.files.length === 0) { @@ -157,9 +251,17 @@ await startEncryption() } - async function startEncryption(): Promise { + // `signingKeys` is supplied by the one-tap flow (pg.prepareSign already ran + // the disclosure); when omitted, encrypt() starts its own Yivi session and + // renders the QR/button widget into #crypt-irma-qr as before. + async function startEncryption(signingKeys?: SigningKeys): Promise { + sending = true encryptState.encryptionState = EncryptionState.Sign + // Fallback (widget) path: retire any idle pre-warmed session so we + // don't leave a second Yivi session dangling. + if (!signingKeys) cancelPrewarm() + // Wait for Svelte to render the Yivi QR element into the DOM await tick() @@ -197,6 +299,7 @@ files: encryptState.files, recipients, sign, + signingKeys, onProgress: (pct) => { // First progress callback means signing is done if (encryptState.encryptionState === EncryptionState.Sign) { @@ -298,6 +401,45 @@ encryptState.encryptionState = EncryptionState.Error } mobilePopupMode = 'none' + } finally { + sending = false + } + } + + // One-tap send (iOS): the send control is an to the pre-warmed + // Yivi app URL, so tapping it opens the app on a genuine gesture. We do NOT + // preventDefault — the navigation is what opens the app. Alongside it we + // wait for the disclosure to complete (the user returns from Yivi), then + // encrypt with the pre-resolved keys. Falls back to onSign() if the + // pre-warmed session vanished between render and tap. + async function onSignOneTap(): Promise { + yiviInterrupted = false + const handle = preparedSign + if (!handle) { + onSign() + return + } + sending = true + mobilePopupMode = 'onetap' + encryptState.encryptionState = EncryptionState.Sign + try { + const signingKeys = await handle.keys + // Consume the session so the pre-warm effect doesn't reuse it. + preparedSign = null + yiviAppUrl = null + await startEncryption(signingKeys) + } catch (e) { + preparedSign = null + yiviAppUrl = null + sending = false + if (e instanceof YiviSessionError) { + // Cancelled / timed out in the app — recover gracefully. + enterInterruptedRecovery() + } else { + encryptState.serverError = false + encryptState.encryptionState = EncryptionState.Error + mobilePopupMode = 'none' + } } } @@ -484,10 +626,28 @@

{/if} + {:else if isMobileDevice && yiviAppUrl && canEncrypt} + + +
+ {$_('filesharing.encryptPanel.encryptSend')} + + {:else} + click surfaces the validation modal explaining what's missing. + On mobile this is also the fallback until the one-tap URL is + ready (or if the pre-warmed session lapsed). -->