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
+ {$_('filesharing.sign.oneTapWaiting')} +
+ +{$_('filesharing.sign.followSteps')} @@ -664,6 +864,13 @@ {/if} + + + {#if isMobileDevice} +
+ {/if}