From 76f645ef55553e008341bbde363bcee78c146a1f Mon Sep 17 00:00:00 2001 From: Proxiweb <208025616+ProxiwebLabs@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:34:38 +0100 Subject: [PATCH 1/4] feat: add Tauri mobile-v1 reference --- .github/workflows/validate.yml | 23 + .gitignore | 2 + AGENTS.md | 1 + README.md | 1 + ai-manifest.json | 7 + docs/agent-integration.md | 7 + docs/mobile-contract.md | 13 + scripts/validate.ps1 | 23 +- scripts/validate.sh | 20 +- tauri/README.md | 85 + tauri/index.html | 13 + tauri/package-lock.json | 1368 +++++++++++++++++ tauri/package.json | 24 + tauri/plugins/deferred-link/.gitignore | 17 + tauri/plugins/deferred-link/Cargo.toml | 17 + tauri/plugins/deferred-link/README.md | 12 + .../plugins/deferred-link/android/.gitignore | 2 + .../deferred-link/android/build.gradle.kts | 45 + .../deferred-link/android/consumer-rules.pro | 1 + .../deferred-link/android/proguard-rules.pro | 21 + .../deferred-link/android/settings.gradle | 31 + .../android/src/main/AndroidManifest.xml | 3 + .../tauri/deferredlink/HandoffParser.kt | 32 + .../tauri/deferredlink/ZqDeferredPlugin.kt | 66 + .../tauri/deferredlink/HandoffParserTest.kt | 23 + tauri/plugins/deferred-link/build.rs | 8 + tauri/plugins/deferred-link/guest-js/index.ts | 10 + tauri/plugins/deferred-link/ios/.gitignore | 10 + tauri/plugins/deferred-link/ios/Package.swift | 36 + tauri/plugins/deferred-link/ios/README.md | 3 + .../ios/Sources/ZqDeferredPlugin.swift | 126 ++ .../PluginTests/HandoffParserTests.swift | 25 + tauri/plugins/deferred-link/package.json | 33 + .../deferred-link/permissions/default.toml | 3 + tauri/plugins/deferred-link/rollup.config.js | 31 + tauri/plugins/deferred-link/src/commands.rs | 12 + tauri/plugins/deferred-link/src/desktop.rs | 20 + tauri/plugins/deferred-link/src/error.rs | 21 + tauri/plugins/deferred-link/src/lib.rs | 48 + tauri/plugins/deferred-link/src/mobile.rs | 34 + tauri/plugins/deferred-link/src/models.rs | 14 + tauri/plugins/deferred-link/tsconfig.json | 14 + tauri/src-tauri/Cargo.toml | 23 + tauri/src-tauri/build.rs | 3 + tauri/src-tauri/capabilities/default.json | 13 + tauri/src-tauri/src/lib.rs | 28 + tauri/src-tauri/src/main.rs | 5 + tauri/src-tauri/tauri.conf.json | 44 + tauri/src/config.ts | 14 + tauri/src/controller.test.ts | 81 + tauri/src/controller.ts | 57 + tauri/src/main.ts | 85 + tauri/src/mobile-v1.ts | 124 ++ tauri/src/routing.test.ts | 39 + tauri/src/routing.ts | 65 + tauri/src/style.css | 31 + tauri/src/types.ts | 37 + tauri/src/zq-deferred.ts | 9 + tauri/tsconfig.json | 20 + tauri/vite.config.ts | 9 + 60 files changed, 2989 insertions(+), 3 deletions(-) create mode 100644 tauri/README.md create mode 100644 tauri/index.html create mode 100644 tauri/package-lock.json create mode 100644 tauri/package.json create mode 100644 tauri/plugins/deferred-link/.gitignore create mode 100644 tauri/plugins/deferred-link/Cargo.toml create mode 100644 tauri/plugins/deferred-link/README.md create mode 100644 tauri/plugins/deferred-link/android/.gitignore create mode 100644 tauri/plugins/deferred-link/android/build.gradle.kts create mode 100644 tauri/plugins/deferred-link/android/consumer-rules.pro create mode 100644 tauri/plugins/deferred-link/android/proguard-rules.pro create mode 100644 tauri/plugins/deferred-link/android/settings.gradle create mode 100644 tauri/plugins/deferred-link/android/src/main/AndroidManifest.xml create mode 100644 tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/HandoffParser.kt create mode 100644 tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/ZqDeferredPlugin.kt create mode 100644 tauri/plugins/deferred-link/android/src/test/java/com/zipquantum/tauri/deferredlink/HandoffParserTest.kt create mode 100644 tauri/plugins/deferred-link/build.rs create mode 100644 tauri/plugins/deferred-link/guest-js/index.ts create mode 100644 tauri/plugins/deferred-link/ios/.gitignore create mode 100644 tauri/plugins/deferred-link/ios/Package.swift create mode 100644 tauri/plugins/deferred-link/ios/README.md create mode 100644 tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift create mode 100644 tauri/plugins/deferred-link/ios/Tests/PluginTests/HandoffParserTests.swift create mode 100644 tauri/plugins/deferred-link/package.json create mode 100644 tauri/plugins/deferred-link/permissions/default.toml create mode 100644 tauri/plugins/deferred-link/rollup.config.js create mode 100644 tauri/plugins/deferred-link/src/commands.rs create mode 100644 tauri/plugins/deferred-link/src/desktop.rs create mode 100644 tauri/plugins/deferred-link/src/error.rs create mode 100644 tauri/plugins/deferred-link/src/lib.rs create mode 100644 tauri/plugins/deferred-link/src/mobile.rs create mode 100644 tauri/plugins/deferred-link/src/models.rs create mode 100644 tauri/plugins/deferred-link/tsconfig.json create mode 100644 tauri/src-tauri/Cargo.toml create mode 100644 tauri/src-tauri/build.rs create mode 100644 tauri/src-tauri/capabilities/default.json create mode 100644 tauri/src-tauri/src/lib.rs create mode 100644 tauri/src-tauri/src/main.rs create mode 100644 tauri/src-tauri/tauri.conf.json create mode 100644 tauri/src/config.ts create mode 100644 tauri/src/controller.test.ts create mode 100644 tauri/src/controller.ts create mode 100644 tauri/src/main.ts create mode 100644 tauri/src/mobile-v1.ts create mode 100644 tauri/src/routing.test.ts create mode 100644 tauri/src/routing.ts create mode 100644 tauri/src/style.css create mode 100644 tauri/src/types.ts create mode 100644 tauri/src/zq-deferred.ts create mode 100644 tauri/tsconfig.json create mode 100644 tauri/vite.config.ts diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e07b39e..e4964ec 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -62,6 +62,29 @@ jobs: - run: flutter analyze - run: flutter test + tauri: + runs-on: ubuntu-latest + defaults: + run: + working-directory: tauri + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: '22' + cache: npm + cache-dependency-path: tauri/package-lock.json + - uses: dtolnay/rust-toolchain@stable + - uses: swatinem/rust-cache@v2 + with: + workspaces: tauri/src-tauri + - run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - run: npm ci + - run: npm test + - run: npm run typecheck + - run: npm run build + - run: cargo test --manifest-path src-tauri/Cargo.toml + ios: runs-on: macos-latest defaults: diff --git a/.gitignore b/.gitignore index 568450b..91598ea 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ DerivedData/ xcuserdata/ local.properties **/node_modules/ +**/dist/ +**/target/ **/.dart_tool/ .flutter-plugins* .playwright-cli/ diff --git a/AGENTS.md b/AGENTS.md index bd94df4..c1d4c78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ This repository contains independent mobile reference apps. Do not add a proprie - `android-kotlin/`: Jetpack Compose example. - `react-native/`: React Native TypeScript example with auditable native adapters. - `flutter/`: Flutter/Dart example with auditable native adapters. +- `tauri/`: Tauri 2 webview app plus a narrow native deferred-link plugin. - `contracts/`: machine-readable API shapes. - `docs/`: human and agent integration guidance. - `scripts/`: non-interactive validation entrypoints. diff --git a/README.md b/README.md index 06e6978..b2864a9 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ These examples use platform APIs directly: | [Kotlin](android-kotlin/) | Verified App Links | Google Play Install Referrer | Signed `route_ack` receipt | | [React Native](react-native/) | `Linking` + verified HTTPS links | Native `UIPasteControl` / Install Referrer adapters | Signed `route_ack` receipt | | [Flutter](flutter/) | `app_links` + verified HTTPS links | Native `UIPasteControl` / Install Referrer adapters | Signed `route_ack` receipt | +| [Tauri 2](tauri/) | Official deep-link plugin + desktop single instance | Native `UIPasteControl` / Install Referrer plugin on mobile | Mobile only, after the destination renders | The sample identifiers and hosts are placeholders. Replace them with values verified in your ZipQuantum dashboard before running an app. diff --git a/ai-manifest.json b/ai-manifest.json index 097636c..c92c441 100644 --- a/ai-manifest.json +++ b/ai-manifest.json @@ -28,6 +28,13 @@ "minimum_version": "3.47.1", "direct_delivery": "app_links with Universal Links or verified App Links", "deferred_delivery": "native UIPasteControl or Google Play Install Referrer adapter" + }, + "tauri": { + "path": "tauri", + "minimum_version": "2.9.5", + "direct_delivery": "official deep-link plugin with desktop single-instance routing", + "deferred_delivery": "one-shot native UIPasteControl or Google Play Install Referrer plugin on mobile", + "route_ack": "mobile only; desktop intentionally unsupported" } }, "privacy": { diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 3d87d48..5c1e65f 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -24,6 +24,13 @@ For React Native, treat `react-native/ios` and `react-native/android` as require For Flutter, treat `flutter/ios` and `flutter/android` the same way. The MethodChannel surface is intentionally narrow and transports only a user-approved iOS handoff or raw Play Install Referrer value. +For Tauri, register `tauri-plugin-single-instance` before +`tauri-plugin-deep-link`. Direct cold-start and warm URLs must enter the same +allowlisted parser. The local deferred-link plugin is one-shot: Android reads +Play Install Referrer once, while iOS presents a visible `UIPasteControl` and +waits for the user's action. Never add a silent clipboard fallback. Do not add a +desktop `route_ack` unless a distinct server-side desktop trust contract exists. + ## Machine behavior - Commands are non-interactive. diff --git a/docs/mobile-contract.md b/docs/mobile-contract.md index 28672aa..f352991 100644 --- a/docs/mobile-contract.md +++ b/docs/mobile-contract.md @@ -22,6 +22,19 @@ Use React Native `Linking` for verified HTTPS entry points. Keep the handoff mec Use `app_links` for verified HTTPS entry points. Keep deferred handoffs native through a Flutter platform view backed by `UIPasteControl` on iOS and a narrow MethodChannel backed by Play Install Referrer on Android. Dart must not read the iOS pasteboard or manufacture a handoff receipt. +## Tauri 2 + +Use the official deep-link plugin for installed links. Register the desktop +single-instance plugin first so cold-start and already-running URLs reach the +same allowlisted route parser. On mobile, keep deferred recovery native: +`UIPasteControl` on iOS and Play Install Referrer on Android. The webview receives +only the one-time handoff fields needed by `mobile-v1`. + +Desktop demonstrates direct custom-scheme routing only. A local webview origin is +not a verified mobile app identity, so desktop must not submit `route_ack` under +`mobile-v1`. A separate server trust contract would be required before adding +desktop acknowledgements or deferred recovery. + ## Route acknowledgement A successful response may contain `route_ack.receipt`, `route_ack.expires_in`, and `route_ack.endpoint`. After the destination route is actually displayed, POST the opaque receipt, host, platform, and registered app identifier to the supplied endpoint. Do not decode, persist, replay, or log the receipt. diff --git a/scripts/validate.ps1 b/scripts/validate.ps1 index 8c2c133..99b7bd8 100644 --- a/scripts/validate.ps1 +++ b/scripts/validate.ps1 @@ -1,4 +1,4 @@ -param([ValidateSet('all','ios','android','react-native','flutter')][string]$Platform = 'all') +param([ValidateSet('all','ios','android','react-native','flutter','tauri')][string]$Platform = 'all') $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $errors = 0 @@ -15,8 +15,9 @@ $sources = Get-ChildItem @( "$root/android-kotlin", "$root/react-native" "$root/flutter" + "$root/tauri" ) -Recurse -File | Where-Object { - $_.FullName -notmatch '[\\/](node_modules|build|\.gradle|\.dart_tool|Pods)[\\/]' + $_.FullName -notmatch '[\\/](node_modules|build|dist|target|\.gradle|\.dart_tool|Pods)[\\/]' } $forbidden = 'AdvertisingIdClient|identifierForVendor|ASIdentifierManager|fingerprintjs|UIPasteboard\.general\.string' $matches = $sources | Select-String -Pattern $forbidden @@ -58,5 +59,23 @@ if ($Platform -in @('all','flutter')) { if ($config -match 'links\.example\.com') { Warn 'flutter_uses_example_host' } else { Ok 'flutter_host_configured' } } +if ($Platform -in @('all','tauri')) { + $rust = Get-Content "$root/tauri/src-tauri/src/lib.rs" -Raw + $frontend = Get-Content "$root/tauri/src/main.ts" -Raw + $controller = Get-Content "$root/tauri/src/controller.ts" -Raw + $ios = Get-Content "$root/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift" -Raw + $android = Get-Content "$root/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/ZqDeferredPlugin.kt" -Raw + $config = Get-Content "$root/tauri/src/config.ts" -Raw + $singleIndex = $rust.IndexOf('tauri_plugin_single_instance::init') + $deepIndex = $rust.IndexOf('tauri_plugin_deep_link::init') + + if ($singleIndex -ge 0 -and $deepIndex -gt $singleIndex) { Ok 'tauri_single_instance_registered_first' } else { Fail 'tauri_single_instance_order_invalid' } + if ($frontend -match 'getCurrent' -and $frontend -match 'onOpenUrl' -and $frontend -match 'handleDirectUrls') { Ok 'tauri_cold_warm_shared_handler' } else { Fail 'tauri_cold_warm_handler_missing' } + if ($ios -match 'UIPasteControl' -and $ios -notmatch 'UIPasteboard\.general') { Ok 'tauri_ios_explicit_paste_control' } else { Fail 'tauri_ios_paste_boundary_invalid' } + if ($android -match 'InstallReferrerClient' -and $android -match 'AtomicBoolean' -and $android -notmatch 'SharedPreferences|android\.util\.Log') { Ok 'tauri_android_one_shot_referrer' } else { Fail 'tauri_android_referrer_boundary_invalid' } + if ($controller -match "platform === 'Desktop'" -and $controller -match 'acknowledgeAfterRender') { Ok 'tauri_desktop_ack_out_of_scope' } else { Fail 'tauri_desktop_ack_boundary_missing' } + if ($config -match 'links\.example\.com' -and $config -match 'com\.example\.zipquantum') { Warn 'tauri_uses_example_identifiers' } else { Ok 'tauri_identifiers_configured' } +} + if ($errors -gt 0) { exit 1 } Ok 'static_validation_complete' diff --git a/scripts/validate.sh b/scripts/validate.sh index 757d765..54c25cb 100644 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -10,7 +10,7 @@ fail() { printf 'ZQ_ERROR %s\n' "$1"; ERRORS=$((ERRORS + 1)); } python3 -m json.tool "$ROOT/contracts/mobile-v1.schema.json" >/dev/null && ok contract_json_valid || fail contract_json_invalid python3 -m json.tool "$ROOT/ai-manifest.json" >/dev/null && ok ai_manifest_valid || fail ai_manifest_invalid -if grep -R -n -E --exclude-dir=node_modules --exclude-dir=.dart_tool 'AdvertisingIdClient|identifierForVendor|ASIdentifierManager|fingerprintjs|UIPasteboard\.general\.string' "$ROOT/ios-swiftui" "$ROOT/android-kotlin" "$ROOT/react-native" "$ROOT/flutter"; then +if grep -R -n -E --exclude-dir=node_modules --exclude-dir=.dart_tool --exclude-dir=dist --exclude-dir=target 'AdvertisingIdClient|identifierForVendor|ASIdentifierManager|fingerprintjs|UIPasteboard\.general\.string' "$ROOT/ios-swiftui" "$ROOT/android-kotlin" "$ROOT/react-native" "$ROOT/flutter" "$ROOT/tauri"; then fail forbidden_privacy_pattern else ok privacy_invariants_static @@ -40,5 +40,23 @@ if [ "$PLATFORM" = all ] || [ "$PLATFORM" = android ]; then grep -q links.example.com "$ROOT/android-kotlin/app/src/main/java/com/example/zipquantum/ZQConfiguration.kt" && warn android_uses_example_host || ok android_host_configured fi +if [ "$PLATFORM" = all ] || [ "$PLATFORM" = tauri ]; then + RUST="$ROOT/tauri/src-tauri/src/lib.rs" + FRONTEND="$ROOT/tauri/src/main.ts" + CONTROLLER="$ROOT/tauri/src/controller.ts" + IOS="$ROOT/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift" + ANDROID="$ROOT/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/ZqDeferredPlugin.kt" + CONFIG="$ROOT/tauri/src/config.ts" + + SINGLE_LINE=$(grep -n 'tauri_plugin_single_instance::init' "$RUST" | head -n 1 | cut -d: -f1 || true) + DEEP_LINE=$(grep -n 'tauri_plugin_deep_link::init' "$RUST" | head -n 1 | cut -d: -f1 || true) + [ -n "$SINGLE_LINE" ] && [ -n "$DEEP_LINE" ] && [ "$SINGLE_LINE" -lt "$DEEP_LINE" ] && ok tauri_single_instance_registered_first || fail tauri_single_instance_order_invalid + grep -q getCurrent "$FRONTEND" && grep -q onOpenUrl "$FRONTEND" && grep -q handleDirectUrls "$FRONTEND" && ok tauri_cold_warm_shared_handler || fail tauri_cold_warm_handler_missing + grep -q UIPasteControl "$IOS" && ! grep -q 'UIPasteboard\.general' "$IOS" && ok tauri_ios_explicit_paste_control || fail tauri_ios_paste_boundary_invalid + grep -q InstallReferrerClient "$ANDROID" && grep -q AtomicBoolean "$ANDROID" && ! grep -q -E 'SharedPreferences|android\.util\.Log' "$ANDROID" && ok tauri_android_one_shot_referrer || fail tauri_android_referrer_boundary_invalid + grep -q "platform === 'Desktop'" "$CONTROLLER" && grep -q acknowledgeAfterRender "$CONTROLLER" && ok tauri_desktop_ack_out_of_scope || fail tauri_desktop_ack_boundary_missing + grep -q links.example.com "$CONFIG" && grep -q com.example.zipquantum "$CONFIG" && warn tauri_uses_example_identifiers || ok tauri_identifiers_configured +fi + [ "$ERRORS" -eq 0 ] || exit 1 ok static_validation_complete diff --git a/tauri/README.md b/tauri/README.md new file mode 100644 index 0000000..ed68885 --- /dev/null +++ b/tauri/README.md @@ -0,0 +1,85 @@ +# Tauri 2 direct and deferred deep links + +This reference app adds Tauri 2 without adding a proprietary ZipQuantum SDK. It +uses the repository's [`mobile-v1`](../contracts/mobile-v1.schema.json) contract +on iOS and Android and keeps the desktop path deliberately narrower. + +## What the example proves + +| Runtime | Direct delivery | Deferred delivery | `route_ack` | +| --- | --- | --- | --- | +| iOS | Universal Link via `tauri-plugin-deep-link` | Visible native `UIPasteControl`, one read | After route render | +| Android | Verified App Link via `tauri-plugin-deep-link` | Play Install Referrer, one read | After route render | +| macOS/Windows/Linux | `zq-example://` + single instance | Out of scope | Never sent | + +The single-instance plugin is registered before the deep-link plugin in +[`src-tauri/src/lib.rs`](src-tauri/src/lib.rs). Both initial launch and a second +instance feed `handleDirectUrls()` in [`src/main.ts`](src/main.ts), which uses the +same allowlisted route parser. + +## Replace placeholders + +Before running a mobile build, replace only with values verified in your +ZipQuantum dashboard: + +- `links.example.com` in `src/config.ts` and `src-tauri/tauri.conf.json`; +- `app.example.com` and the allowlisted application routes in `src/routing.ts`; +- `com.example.zipquantum` app identifiers; +- `com.example.zipquantum.tauri` Tauri bundle identifier. + +Do not commit a live token, signing certificate, provisioning profile, handoff +URL, route receipt, or user data. + +## Run locally + +```sh +npm ci +npm test +npm run typecheck +npm run build +npm run tauri dev +``` + +The Rust shell lives in `src-tauri/`; the mobile adapter is the path dependency +`plugins/deferred-link/`. A platform build also requires the normal Tauri 2 +Android or iOS prerequisites. + +## Delivery sequence + +1. The deep-link plugin supplies the cold-start URL through `getCurrent()` or a + warm URL through `onOpenUrl()`. +2. Mobile verifies the incoming host and calls the appropriate `mobile-v1` + endpoint. Desktop parses only the configured custom scheme locally. +3. The response destination is reduced to an allowlisted route; arbitrary web + destinations are rejected. +4. The app renders the destination. +5. Mobile forwards the opaque server receipt once. Desktop does not acknowledge. + +For deferred mobile delivery, `getPendingRoute()` can be consumed only once per +process. Android extracts `zq_token` and `zq_host` from Play Install Referrer. +iOS opens a native modal whose paste action is implemented by `UIPasteControl`. +There is no clipboard read at launch or generic clipboard API. + +## Receipt boundary + +The receipt is held only in the pending in-memory navigation closure. The client +does not manufacture, decode, sign, persist, print, or replay it. Error messages +never include a full URL, handoff token, or receipt. Desktop `route_ack` remains +out of scope until ZipQuantum defines a separate desktop identity and trust +contract. + +## Deterministic validation + +From the repository root: + +```powershell +powershell -File scripts/validate.ps1 tauri +``` + +```sh +./scripts/validate.sh tauri +``` + +The checks use stable `ZQ_OK`, `ZQ_WARN`, and `ZQ_ERROR` prefixes. The TypeScript +tests cover cold/warm direct delivery, deferred delivery, unknown routes, +one-shot acknowledgement, and the desktop no-ack boundary. diff --git a/tauri/index.html b/tauri/index.html new file mode 100644 index 0000000..198f3f6 --- /dev/null +++ b/tauri/index.html @@ -0,0 +1,13 @@ + + + + + + + ZipQuantum Tauri example + + +
+ + + diff --git a/tauri/package-lock.json b/tauri/package-lock.json new file mode 100644 index 0000000..e0f7af1 --- /dev/null +++ b/tauri/package-lock.json @@ -0,0 +1,1368 @@ +{ + "name": "zipquantum-tauri-mobile-v1-example", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zipquantum-tauri-mobile-v1-example", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-deep-link": "^2.4.10", + "@tauri-apps/plugin-os": "^2.3.2" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.9.6", + "typescript": "^5.9.2", + "vite": "^8.3.0", + "vitest": "^5.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.10.tgz", + "integrity": "sha512-SuXI47ORUzYuwG6c8dluLDrStTvli5bAZDRa2+VuUEzK/9TEa7FZz7UKXBQriL7+CPFXRHQsz5tSocc47tRXQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-os": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz", + "integrity": "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/tauri/package.json b/tauri/package.json new file mode 100644 index 0000000..c2dfd7d --- /dev/null +++ b/tauri/package.json @@ -0,0 +1,24 @@ +{ + "name": "zipquantum-tauri-mobile-v1-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit && vite build", + "dev": "vite", + "tauri": "tauri", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-deep-link": "^2.4.10", + "@tauri-apps/plugin-os": "^2.3.2" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.9.6", + "typescript": "^5.9.2", + "vite": "^8.3.0", + "vitest": "^5.0.0" + } +} diff --git a/tauri/plugins/deferred-link/.gitignore b/tauri/plugins/deferred-link/.gitignore new file mode 100644 index 0000000..50d8e32 --- /dev/null +++ b/tauri/plugins/deferred-link/.gitignore @@ -0,0 +1,17 @@ +/.vs +.DS_Store +.Thumbs.db +*.sublime* +.idea/ +debug.log +package-lock.json +.vscode/settings.json +yarn.lock + +/.tauri +/target +Cargo.lock +node_modules/ + +dist-js +dist diff --git a/tauri/plugins/deferred-link/Cargo.toml b/tauri/plugins/deferred-link/Cargo.toml new file mode 100644 index 0000000..ead79f1 --- /dev/null +++ b/tauri/plugins/deferred-link/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tauri-plugin-zq-deferred" +version = "0.1.0" +authors = ["ZipQuantum"] +description = "One-shot, native deferred-link handoff for the ZipQuantum Tauri example" +edition = "2021" +rust-version = "1.77.2" +exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"] +links = "tauri-plugin-zq-deferred" + +[dependencies] +tauri = { version = "2.9.5" } +serde = { version = "1.0", features = ["derive"] } +thiserror = "2" + +[build-dependencies] +tauri-plugin = { version = "2.5.2", features = ["build"] } diff --git a/tauri/plugins/deferred-link/README.md b/tauri/plugins/deferred-link/README.md new file mode 100644 index 0000000..110d2fd --- /dev/null +++ b/tauri/plugins/deferred-link/README.md @@ -0,0 +1,12 @@ +# Tauri plugin: `zq-deferred` + +This local Tauri 2 plugin exposes one command: `getPendingRoute()`. + +- Android connects to Google Play Install Referrer once and returns only a + validated `zq_token`/`zq_host` pair. +- iOS presents a native `UIPasteControl`; the pasteboard is never read silently. +- Desktop always returns no handoff. Desktop deferred recovery is out of scope. + +The command is process-local and one-shot. It never persists or logs the +referrer, token, full URL, or route receipt. The webview must validate the host +again before sending the handoff to the ZipQuantum `mobile-v1` endpoint. diff --git a/tauri/plugins/deferred-link/android/.gitignore b/tauri/plugins/deferred-link/android/.gitignore new file mode 100644 index 0000000..c0f21ec --- /dev/null +++ b/tauri/plugins/deferred-link/android/.gitignore @@ -0,0 +1,2 @@ +/build +/.tauri diff --git a/tauri/plugins/deferred-link/android/build.gradle.kts b/tauri/plugins/deferred-link/android/build.gradle.kts new file mode 100644 index 0000000..b2fd9d3 --- /dev/null +++ b/tauri/plugins/deferred-link/android/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.zipquantum.tauri.deferredlink" + compileSdk = 36 + + defaultConfig { + minSdk = 23 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.appcompat:appcompat:1.6.0") + implementation("com.google.android.material:material:1.7.0") + implementation("com.android.installreferrer:installreferrer:2.2") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(project(":tauri-android")) +} diff --git a/tauri/plugins/deferred-link/android/consumer-rules.pro b/tauri/plugins/deferred-link/android/consumer-rules.pro new file mode 100644 index 0000000..f0fb6af --- /dev/null +++ b/tauri/plugins/deferred-link/android/consumer-rules.pro @@ -0,0 +1 @@ +# The plugin does not use reflection beyond Tauri's generated bindings. diff --git a/tauri/plugins/deferred-link/android/proguard-rules.pro b/tauri/plugins/deferred-link/android/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/tauri/plugins/deferred-link/android/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/tauri/plugins/deferred-link/android/settings.gradle b/tauri/plugins/deferred-link/android/settings.gradle new file mode 100644 index 0000000..d7782a4 --- /dev/null +++ b/tauri/plugins/deferred-link/android/settings.gradle @@ -0,0 +1,31 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + google() + } + resolutionStrategy { + eachPlugin { + switch (requested.id.id) { + case "com.android.library": + useVersion("8.0.2") + break + case "org.jetbrains.kotlin.android": + useVersion("1.8.20") + break + } + } + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + google() + + } +} + +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/tauri/plugins/deferred-link/android/src/main/AndroidManifest.xml b/tauri/plugins/deferred-link/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/tauri/plugins/deferred-link/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/HandoffParser.kt b/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/HandoffParser.kt new file mode 100644 index 0000000..3b2b6bb --- /dev/null +++ b/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/HandoffParser.kt @@ -0,0 +1,32 @@ +package com.zipquantum.tauri.deferredlink + +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +data class DeferredHandoff(val token: String, val host: String) + +object HandoffParser { + private val tokenPattern = Regex("^[A-Za-z0-9._~-]{1,512}$") + private val hostPattern = Regex("^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}$") + + fun parse(referrer: String): DeferredHandoff? { + val fields = mutableMapOf() + for (part in referrer.split('&')) { + if (part.isBlank()) continue + val separator = part.indexOf('=') + if (separator <= 0) continue + val key = decode(part.substring(0, separator)) + if (key != "zq_token" && key != "zq_host") continue + if (fields.containsKey(key)) return null + fields[key] = decode(part.substring(separator + 1)).trim() + } + + val token = fields["zq_token"] ?: return null + val host = fields["zq_host"]?.lowercase() ?: return null + if (!tokenPattern.matches(token) || !hostPattern.matches(host)) return null + return DeferredHandoff(token, host) + } + + private fun decode(value: String): String = + URLDecoder.decode(value, StandardCharsets.UTF_8.name()) +} diff --git a/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/ZqDeferredPlugin.kt b/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/ZqDeferredPlugin.kt new file mode 100644 index 0000000..d23ee46 --- /dev/null +++ b/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/ZqDeferredPlugin.kt @@ -0,0 +1,66 @@ +package com.zipquantum.tauri.deferredlink + +import android.app.Activity +import app.tauri.annotation.Command +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin +import com.android.installreferrer.api.InstallReferrerClient +import com.android.installreferrer.api.InstallReferrerStateListener +import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse +import java.util.concurrent.atomic.AtomicBoolean + +@TauriPlugin +class ZqDeferredPlugin(private val activity: Activity) : Plugin(activity) { + private val requested = AtomicBoolean(false) + + @Command + fun getPendingRoute(invoke: Invoke) { + if (!requested.compareAndSet(false, true)) { + invoke.resolve(emptyResponse()) + return + } + + val client = InstallReferrerClient.newBuilder(activity).build() + val completed = AtomicBoolean(false) + + fun finish(response: JSObject) { + if (!completed.compareAndSet(false, true)) return + runCatching { client.endConnection() } + invoke.resolve(response) + } + + fun failClosed() = finish(emptyResponse()) + + client.startConnection(object : InstallReferrerStateListener { + override fun onInstallReferrerSetupFinished(responseCode: Int) { + if (responseCode != InstallReferrerResponse.OK) { + failClosed() + return + } + + val handoff = runCatching { + HandoffParser.parse(client.installReferrer.installReferrer) + }.getOrNull() + + if (handoff == null) { + failClosed() + return + } + + val value = JSObject().apply { + put("token", handoff.token) + put("host", handoff.host) + } + finish(JSObject().apply { put("handoff", value) }) + } + + override fun onInstallReferrerServiceDisconnected() { + failClosed() + } + }) + } + + private fun emptyResponse(): JSObject = JSObject().apply { put("handoff", null) } +} diff --git a/tauri/plugins/deferred-link/android/src/test/java/com/zipquantum/tauri/deferredlink/HandoffParserTest.kt b/tauri/plugins/deferred-link/android/src/test/java/com/zipquantum/tauri/deferredlink/HandoffParserTest.kt new file mode 100644 index 0000000..ea27abf --- /dev/null +++ b/tauri/plugins/deferred-link/android/src/test/java/com/zipquantum/tauri/deferredlink/HandoffParserTest.kt @@ -0,0 +1,23 @@ +package com.zipquantum.tauri.deferredlink + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HandoffParserTest { + @Test + fun parsesExpectedKeys() { + assertEquals( + DeferredHandoff("opaque_token", "links.example.com"), + HandoffParser.parse("utm_source=example&zq_token=opaque_token&zq_host=links.example.com") + ) + } + + @Test + fun rejectsMissingDuplicateOrMalformedValues() { + assertNull(HandoffParser.parse("zq_host=links.example.com")) + assertNull(HandoffParser.parse("zq_token=one&zq_token=two&zq_host=links.example.com")) + assertNull(HandoffParser.parse("zq_token=opaque_token&zq_host=not_a_host")) + assertNull(HandoffParser.parse("zq_token=has%20spaces&zq_host=links.example.com")) + } +} diff --git a/tauri/plugins/deferred-link/build.rs b/tauri/plugins/deferred-link/build.rs new file mode 100644 index 0000000..eb33eb2 --- /dev/null +++ b/tauri/plugins/deferred-link/build.rs @@ -0,0 +1,8 @@ +const COMMANDS: &[&str] = &["get_pending_route"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .android_path("android") + .ios_path("ios") + .build(); +} diff --git a/tauri/plugins/deferred-link/guest-js/index.ts b/tauri/plugins/deferred-link/guest-js/index.ts new file mode 100644 index 0000000..016f161 --- /dev/null +++ b/tauri/plugins/deferred-link/guest-js/index.ts @@ -0,0 +1,10 @@ +import { invoke } from '@tauri-apps/api/core' + +export type DeferredHandoff = { token: string; host: string } + +export async function getPendingRoute(): Promise { + const response = await invoke<{handoff?: DeferredHandoff | null}>( + 'plugin:zq-deferred|get_pending_route' + ) + return response.handoff ?? null +} diff --git a/tauri/plugins/deferred-link/ios/.gitignore b/tauri/plugins/deferred-link/ios/.gitignore new file mode 100644 index 0000000..5922fda --- /dev/null +++ b/tauri/plugins/deferred-link/ios/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/tauri/plugins/deferred-link/ios/Package.swift b/tauri/plugins/deferred-link/ios/Package.swift new file mode 100644 index 0000000..7d1b4d1 --- /dev/null +++ b/tauri/plugins/deferred-link/ios/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version:5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "tauri-plugin-zq-deferred", + platforms: [ + .macOS(.v10_13), + .iOS(.v16), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-zq-deferred", + type: .static, + targets: ["tauri-plugin-zq-deferred"]), + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-zq-deferred", + dependencies: [ + .byName(name: "Tauri") + ], + path: "Sources"), + .testTarget( + name: "PluginTests", + dependencies: ["tauri-plugin-zq-deferred"], + path: "Tests/PluginTests") + ] +) diff --git a/tauri/plugins/deferred-link/ios/README.md b/tauri/plugins/deferred-link/ios/README.md new file mode 100644 index 0000000..6fa8a26 --- /dev/null +++ b/tauri/plugins/deferred-link/ios/README.md @@ -0,0 +1,3 @@ +# Tauri Plugin zq-deferred + +A description of this package. diff --git a/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift b/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift new file mode 100644 index 0000000..f073560 --- /dev/null +++ b/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift @@ -0,0 +1,126 @@ +import SwiftRs +import Tauri +import UIKit +import WebKit + +struct DeferredHandoff: Equatable { + let token: String + let host: String +} + +enum HandoffParser { + static func parse(_ value: String, expectedBundleID: String) -> DeferredHandoff? { + guard let components = URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines)), + components.scheme == "zqddl" + else { return nil } + + let items = components.queryItems ?? [] + func unique(_ name: String) -> String? { + let values = items.filter { $0.name == name }.compactMap(\.value) + return values.count == 1 ? values[0].trimmingCharacters(in: .whitespacesAndNewlines) : nil + } + + guard let token = unique("token"), token.range(of: "^[A-Za-z0-9._~-]{1,512}$", options: .regularExpression) != nil, + let host = unique("host")?.lowercased(), host.range(of: "^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}$", options: .regularExpression) != nil, + unique("bundle_id") == expectedBundleID + else { return nil } + + return DeferredHandoff(token: token, host: host) + } +} + +@available(iOS 16.0, *) +final class PasteControlViewController: UIViewController, UIPasteConfigurationSupporting { + var pasteConfiguration: UIPasteConfiguration? = UIPasteConfiguration(forAccepting: NSString.self) + private let completion: (DeferredHandoff?) -> Void + private var completed = false + + init(completion: @escaping (DeferredHandoff?) -> Void) { + self.completion = completion + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + let title = UILabel() + title.text = "Recover your ZipQuantum link" + title.font = .preferredFont(forTextStyle: .title2) + title.textAlignment = .center + + let paste = UIPasteControl(configuration: .init()) + paste.target = self + + let cancel = UIButton(type: .system) + cancel.setTitle("Cancel", for: .normal) + cancel.addTarget(self, action: #selector(cancelled), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [title, paste, cancel]) + stack.axis = .vertical + stack.spacing = 24 + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + func paste(itemProviders: [NSItemProvider]) { + guard !completed, let provider = itemProviders.first else { return } + provider.loadObject(ofClass: NSString.self) { [weak self] value, _ in + DispatchQueue.main.async { + guard let self, !self.completed else { return } + let bundleID = Bundle.main.bundleIdentifier ?? "" + let handoff = (value as? String).flatMap { HandoffParser.parse($0, expectedBundleID: bundleID) } + self.finish(handoff) + } + } + } + + @objc private func cancelled() { finish(nil) } + + private func finish(_ handoff: DeferredHandoff?) { + guard !completed else { return } + completed = true + dismiss(animated: true) { self.completion(handoff) } + } +} + +final class ZqDeferredPlugin: Plugin { + private var requested = false + + @objc public func getPendingRoute(_ invoke: Invoke) { + DispatchQueue.main.async { + guard !self.requested else { + invoke.resolve(["handoff": NSNull()]) + return + } + self.requested = true + + guard #available(iOS 16.0, *) else { + invoke.resolve(["handoff": NSNull()]) + return + } + + let controller = PasteControlViewController { handoff in + guard let handoff else { + invoke.resolve(["handoff": NSNull()]) + return + } + invoke.resolve(["handoff": ["token": handoff.token, "host": handoff.host]]) + } + controller.modalPresentationStyle = .formSheet + self.presentViewController(controller) + } + } +} + +@_cdecl("init_plugin_zq_deferred") +func initPlugin() -> Plugin { + ZqDeferredPlugin() +} diff --git a/tauri/plugins/deferred-link/ios/Tests/PluginTests/HandoffParserTests.swift b/tauri/plugins/deferred-link/ios/Tests/PluginTests/HandoffParserTests.swift new file mode 100644 index 0000000..ef8b975 --- /dev/null +++ b/tauri/plugins/deferred-link/ios/Tests/PluginTests/HandoffParserTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import tauri_plugin_zq_deferred + +final class HandoffParserTests: XCTestCase { + func testExpectedHandoff() { + XCTAssertEqual( + HandoffParser.parse( + "zqddl://recover?token=opaque_token&host=links.example.com&bundle_id=com.example.zipquantum", + expectedBundleID: "com.example.zipquantum" + ), + DeferredHandoff(token: "opaque_token", host: "links.example.com") + ) + } + + func testRejectsMismatchAndDuplicates() { + XCTAssertNil(HandoffParser.parse( + "zqddl://recover?token=opaque_token&host=links.example.com&bundle_id=other.example", + expectedBundleID: "com.example.zipquantum" + )) + XCTAssertNil(HandoffParser.parse( + "zqddl://recover?token=one&token=two&host=links.example.com&bundle_id=com.example.zipquantum", + expectedBundleID: "com.example.zipquantum" + )) + } +} diff --git a/tauri/plugins/deferred-link/package.json b/tauri/plugins/deferred-link/package.json new file mode 100644 index 0000000..71faddc --- /dev/null +++ b/tauri/plugins/deferred-link/package.json @@ -0,0 +1,33 @@ +{ + "name": "tauri-plugin-zq-deferred-api", + "version": "0.1.0", + "author": "ZipQuantum", + "description": "One-shot native deferred-link handoff for the ZipQuantum Tauri example", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "files": [ + "dist-js", + "README.md" + ], + "scripts": { + "build": "rollup -c", + "prepublishOnly": "pnpm build", + "pretest": "pnpm build" + }, + "dependencies": { + "@tauri-apps/api": "^2.9.1" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^12.1.4", + "rollup": "^4.63.2", + "typescript": "^5.9.2", + "tslib": "^2.8.1" + } +} diff --git a/tauri/plugins/deferred-link/permissions/default.toml b/tauri/plugins/deferred-link/permissions/default.toml new file mode 100644 index 0000000..d8655db --- /dev/null +++ b/tauri/plugins/deferred-link/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for the plugin" +permissions = ["allow-get-pending-route"] diff --git a/tauri/plugins/deferred-link/rollup.config.js b/tauri/plugins/deferred-link/rollup.config.js new file mode 100644 index 0000000..8b4768f --- /dev/null +++ b/tauri/plugins/deferred-link/rollup.config.js @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { cwd } from 'node:process' +import typescript from '@rollup/plugin-typescript' + +const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8')) + +export default { + input: 'guest-js/index.ts', + output: [ + { + file: pkg.exports.import, + format: 'esm' + }, + { + file: pkg.exports.require, + format: 'cjs' + } + ], + plugins: [ + typescript({ + declaration: true, + declarationDir: dirname(pkg.exports.import) + }) + ], + external: [ + /^@tauri-apps\/api/, + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}) + ] +} diff --git a/tauri/plugins/deferred-link/src/commands.rs b/tauri/plugins/deferred-link/src/commands.rs new file mode 100644 index 0000000..867da16 --- /dev/null +++ b/tauri/plugins/deferred-link/src/commands.rs @@ -0,0 +1,12 @@ +use tauri::{AppHandle, command, Runtime}; + +use crate::models::PendingHandoffResponse; +use crate::Result; +use crate::ZqDeferredExt; + +#[command] +pub(crate) async fn get_pending_route( + app: AppHandle, +) -> Result { + app.zq_deferred().get_pending_route() +} diff --git a/tauri/plugins/deferred-link/src/desktop.rs b/tauri/plugins/deferred-link/src/desktop.rs new file mode 100644 index 0000000..56b3805 --- /dev/null +++ b/tauri/plugins/deferred-link/src/desktop.rs @@ -0,0 +1,20 @@ +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +use crate::models::*; + +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(ZqDeferred(app.clone())) +} + +/// Access to the zq-deferred APIs. +pub struct ZqDeferred(AppHandle); + +impl ZqDeferred { + pub fn get_pending_route(&self) -> crate::Result { + Ok(PendingHandoffResponse { handoff: None }) + } +} diff --git a/tauri/plugins/deferred-link/src/error.rs b/tauri/plugins/deferred-link/src/error.rs new file mode 100644 index 0000000..895220d --- /dev/null +++ b/tauri/plugins/deferred-link/src/error.rs @@ -0,0 +1,21 @@ +use serde::{ser::Serializer, Serialize}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[cfg(mobile)] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/tauri/plugins/deferred-link/src/lib.rs b/tauri/plugins/deferred-link/src/lib.rs new file mode 100644 index 0000000..5fe3fa5 --- /dev/null +++ b/tauri/plugins/deferred-link/src/lib.rs @@ -0,0 +1,48 @@ +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub use models::*; + +#[cfg(desktop)] +mod desktop; +#[cfg(mobile)] +mod mobile; + +mod commands; +mod error; +mod models; + +pub use error::{Error, Result}; + +#[cfg(desktop)] +use desktop::ZqDeferred; +#[cfg(mobile)] +use mobile::ZqDeferred; + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the zq-deferred APIs. +pub trait ZqDeferredExt { + fn zq_deferred(&self) -> &ZqDeferred; +} + +impl> crate::ZqDeferredExt for T { + fn zq_deferred(&self) -> &ZqDeferred { + self.state::>().inner() + } +} + +/// Initializes the plugin. +pub fn init() -> TauriPlugin { + Builder::new("zq-deferred") + .invoke_handler(tauri::generate_handler![commands::get_pending_route]) + .setup(|app, api| { + #[cfg(mobile)] + let zq_deferred = mobile::init(app, api)?; + #[cfg(desktop)] + let zq_deferred = desktop::init(app, api)?; + app.manage(zq_deferred); + Ok(()) + }) + .build() +} diff --git a/tauri/plugins/deferred-link/src/mobile.rs b/tauri/plugins/deferred-link/src/mobile.rs new file mode 100644 index 0000000..46a80d9 --- /dev/null +++ b/tauri/plugins/deferred-link/src/mobile.rs @@ -0,0 +1,34 @@ +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::models::*; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_zq_deferred); + +// initializes the Kotlin or Swift plugin classes +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin("com.zipquantum.tauri.deferredlink", "ZqDeferredPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_zq_deferred)?; + Ok(ZqDeferred(handle)) +} + +/// Access to the zq-deferred APIs. +pub struct ZqDeferred(PluginHandle); + +impl ZqDeferred { + pub fn get_pending_route(&self) -> crate::Result { + self + .0 + .run_mobile_plugin("getPendingRoute", ()) + .map_err(Into::into) + } +} diff --git a/tauri/plugins/deferred-link/src/models.rs b/tauri/plugins/deferred-link/src/models.rs new file mode 100644 index 0000000..979df45 --- /dev/null +++ b/tauri/plugins/deferred-link/src/models.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeferredHandoff { + pub token: String, + pub host: String, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingHandoffResponse { + pub handoff: Option, +} diff --git a/tauri/plugins/deferred-link/tsconfig.json b/tauri/plugins/deferred-link/tsconfig.json new file mode 100644 index 0000000..0591122 --- /dev/null +++ b/tauri/plugins/deferred-link/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noImplicitAny": true, + "noEmit": true + }, + "include": ["guest-js/*.ts"], + "exclude": ["dist-js", "node_modules"] +} diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml new file mode 100644 index 0000000..d142d56 --- /dev/null +++ b/tauri/src-tauri/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "zipquantum-tauri-example" +version = "0.1.0" +description = "Auditable Tauri 2 example for ZipQuantum mobile-v1" +authors = ["ZipQuantum"] +edition = "2021" +rust-version = "1.77.2" + +[lib] +name = "zipquantum_tauri_example" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.5.1", features = [] } + +[dependencies] +tauri = { version = "2.9.5", features = [] } +tauri-plugin-deep-link = "2.4.10" +tauri-plugin-os = "2.3.2" +tauri-plugin-zq-deferred = { path = "../plugins/deferred-link" } + +[target.'cfg(desktop)'.dependencies] +tauri-plugin-single-instance = { version = "2.4.4", features = ["deep-link"] } diff --git a/tauri/src-tauri/build.rs b/tauri/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/tauri/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/tauri/src-tauri/capabilities/default.json b/tauri/src-tauri/capabilities/default.json new file mode 100644 index 0000000..0b07589 --- /dev/null +++ b/tauri/src-tauri/capabilities/default.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main-capability", + "description": "Minimum permissions for the ZipQuantum reference app", + "windows": ["main"], + "permissions": [ + "core:default", + "core:event:default", + "deep-link:default", + "os:default", + "zq-deferred:default" + ] +} diff --git a/tauri/src-tauri/src/lib.rs b/tauri/src-tauri/src/lib.rs new file mode 100644 index 0000000..bed8dc8 --- /dev/null +++ b/tauri/src-tauri/src/lib.rs @@ -0,0 +1,28 @@ +#[cfg(desktop)] +use tauri::Manager; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let mut builder = tauri::Builder::default(); + + // ZQ_INVARIANT_SINGLE_INSTANCE_FIRST: Windows/Linux deliver a warm URL to + // a second process. This plugin must be registered before deep-link so its + // `deep-link` feature can forward that URL to the first process. + #[cfg(desktop)] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + // Never print arguments: they can contain a full route URL. + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + })); + } + + builder + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_zq_deferred::init()) + .run(tauri::generate_context!()) + .expect("failed to run ZipQuantum Tauri example"); +} diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs new file mode 100644 index 0000000..f0b8eae --- /dev/null +++ b/tauri/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + zipquantum_tauri_example::run(); +} diff --git a/tauri/src-tauri/tauri.conf.json b/tauri/src-tauri/tauri.conf.json new file mode 100644 index 0000000..22ed5c8 --- /dev/null +++ b/tauri/src-tauri/tauri.conf.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "ZipQuantum Tauri Example", + "version": "0.1.0", + "identifier": "com.example.zipquantum.tauri", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "ZipQuantum Tauri example", + "width": 760, + "height": 620, + "minWidth": 360, + "minHeight": 520 + } + ], + "security": { + "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://a.zq.tn; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-src 'none'" + } + }, + "bundle": { + "active": false + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["zq-example"] + }, + "mobile": [ + { + "scheme": ["https"], + "host": "links.example.com", + "pathPrefix": ["/open"], + "appLink": true + } + ] + } + } +} diff --git a/tauri/src/config.ts b/tauri/src/config.ts new file mode 100644 index 0000000..09c2cc9 --- /dev/null +++ b/tauri/src/config.ts @@ -0,0 +1,14 @@ +export const ZQ_CONFIG = Object.freeze({ + apiBaseURL: 'https://a.zq.tn', + allowedLinkHosts: Object.freeze(['links.example.com']), + allowedDestinationHost: 'app.example.com', + appIdentifiers: Object.freeze({ + iOS: 'com.example.zipquantum', + Android: 'com.example.zipquantum', + }), + consentVersion: 'example-1', + desktopScheme: 'zq-example:', +}); + +export type MobilePlatform = keyof typeof ZQ_CONFIG.appIdentifiers; +export type RuntimePlatform = MobilePlatform | 'Desktop'; diff --git a/tauri/src/controller.test.ts b/tauri/src/controller.test.ts new file mode 100644 index 0000000..fdec725 --- /dev/null +++ b/tauri/src/controller.test.ts @@ -0,0 +1,81 @@ +import {describe, expect, it, vi} from 'vitest'; +import {DeliveryController} from './controller'; + +function response(body: unknown): Response { + return new Response(JSON.stringify(body), {status: 200}); +} + +function delivery(receipt = 'opaque-server-value') { + return { + success: true, + delivery: 'deep_link', + link: { + url: 'https://links.example.com/open/abc123', + host: 'links.example.com', + destination_url: 'https://app.example.com/product/example-42', + }, + route_ack: { + receipt, + expires_in: 120, + endpoint: '/api/mobile/v1/events/route-opened', + }, + }; +} + +describe('direct and deferred delivery', () => { + it('uses the same mobile path for cold and warm direct delivery', async () => { + const fetcher = vi.fn().mockImplementation(async () => response(delivery())); + const controller = new DeliveryController('Android', fetcher); + + const cold = await controller.direct('https://links.example.com/open/cold'); + const warm = await controller.direct('https://links.example.com/open/warm'); + + expect(cold.route).toEqual(warm.route); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher.mock.calls.map((call) => String(call[0]))).toEqual([ + 'https://a.zq.tn/api/mobile/v1/links/resolve', + 'https://a.zq.tn/api/mobile/v1/links/resolve', + ]); + }); + + it('acknowledges once and only after the caller renders', async () => { + const fetcher = vi.fn() + .mockResolvedValueOnce(response(delivery())) + .mockResolvedValueOnce(response({success: true})); + const navigation = await new DeliveryController('iOS', fetcher) + .direct('https://links.example.com/open/abc123'); + + expect(fetcher).toHaveBeenCalledTimes(1); + await navigation.acknowledgeAfterRender(); + await navigation.acknowledgeAfterRender(); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(String(fetcher.mock.calls[1]?.[0])).toBe('https://a.zq.tn/api/mobile/v1/events/route-opened'); + }); + + it('recovers deferred mobile delivery without exposing another path', async () => { + const deferred = {...delivery(), delivery: 'deferred_deep_link'}; + const fetcher = vi.fn().mockResolvedValue(response(deferred)); + const navigation = await new DeliveryController('Android', fetcher).deferred({ + token: 'opaque_token_value', + host: 'links.example.com', + }); + + expect(navigation.route).toEqual({kind: 'product', identifier: 'example-42'}); + expect(String(fetcher.mock.calls[0]?.[0])).toBe('https://a.zq.tn/api/mobile/v1/deferred/recover'); + }); + + it('never emits a desktop route acknowledgement', async () => { + const fetcher = vi.fn(); + const navigation = await new DeliveryController('Desktop', fetcher) + .direct('zq-example://campaign/launch_1'); + await navigation.acknowledgeAfterRender(); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('rejects mobile-v1 use from desktop deferred recovery', async () => { + await expect(new DeliveryController('Desktop').deferred({ + token: 'opaque_token_value', + host: 'links.example.com', + })).rejects.toThrow(/Desktop/); + }); +}); diff --git a/tauri/src/controller.ts b/tauri/src/controller.ts new file mode 100644 index 0000000..6ab9096 --- /dev/null +++ b/tauri/src/controller.ts @@ -0,0 +1,57 @@ +import type {MobilePlatform, RuntimePlatform} from './config'; +import {acknowledgeRouteOpened, recoverDeferredLink, resolveDirectLink, type FetchLike} from './mobile-v1'; +import {parseDeliveryRoute, parseDesktopRoute} from './routing'; +import type {DeferredHandoff, Delivery, PendingNavigation} from './types'; + +function mobilePlatform(platform: RuntimePlatform): MobilePlatform { + if (platform === 'Desktop') throw new Error('Desktop cannot use the mobile-v1 contract'); + return platform; +} + +export class DeliveryController { + constructor( + private readonly platform: RuntimePlatform, + private readonly fetcher: FetchLike = fetch, + ) {} + + async direct(value: string): Promise { + if (this.platform === 'Desktop') { + return { + route: parseDesktopRoute(value), + acknowledgeAfterRender: async () => undefined, + }; + } + const delivery = await resolveDirectLink(value, this.platform, this.fetcher); + return this.mobileNavigation(delivery); + } + + async deferred(handoff: DeferredHandoff): Promise { + const delivery = await recoverDeferredLink( + handoff, + mobilePlatform(this.platform), + this.fetcher, + ); + return this.mobileNavigation(delivery); + } + + private mobileNavigation(delivery: Delivery): PendingNavigation { + const route = parseDeliveryRoute(delivery); + const acknowledgement = delivery.route_ack; + const host = delivery.link.host ?? new URL(delivery.link.url).host; + let acknowledgementAttempted = false; + + return { + route, + acknowledgeAfterRender: async () => { + if (!acknowledgement || acknowledgementAttempted) return; + acknowledgementAttempted = true; + await acknowledgeRouteOpened( + acknowledgement, + host, + mobilePlatform(this.platform), + this.fetcher, + ); + }, + }; + } +} diff --git a/tauri/src/main.ts b/tauri/src/main.ts new file mode 100644 index 0000000..d89db5f --- /dev/null +++ b/tauri/src/main.ts @@ -0,0 +1,85 @@ +import {getCurrent, onOpenUrl} from '@tauri-apps/plugin-deep-link'; +import {type} from '@tauri-apps/plugin-os'; +import {DeliveryController} from './controller'; +import './style.css'; +import type {RuntimePlatform} from './config'; +import type {PendingNavigation} from './types'; +import {getPendingRoute} from './zq-deferred'; + +const appElement = document.querySelector('#app'); +if (!appElement) throw new Error('Missing application root'); +const root: HTMLElement = appElement; + +function runtimePlatform(): RuntimePlatform { + const current = type(); + if (current === 'ios') return 'iOS'; + if (current === 'android') return 'Android'; + return 'Desktop'; +} + +const platform = runtimePlatform(); +const controller = new DeliveryController(platform); + +function renderStatus(message: string, isError = false): void { + root.innerHTML = ` +
+ ZipQuantum ยท Tauri 2 +

${isError ? 'Link not opened' : 'Ready for a verified link'}

+

${message}

+ ${platform === 'Desktop' ? '' : ''} +

No fingerprinting. No advertising identifier. Receipts stay opaque and in memory.

+
`; + root.querySelector('#recover')?.addEventListener('click', () => { + void recoverDeferred(); + }); +} + +async function renderNavigation(navigation: PendingNavigation): Promise { + root.innerHTML = ` +
+ Route opened +

${navigation.route.kind}

+

+

The route acknowledgement is sent only after this view renders.

+
`; + const routeId = root.querySelector('.route-id'); + if (routeId) routeId.textContent = navigation.route.identifier; + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await navigation.acknowledgeAfterRender(); +} + +async function handleDirectUrls(values: string[]): Promise { + for (const value of values) { + try { + await renderNavigation(await controller.direct(value)); + return; + } catch { + // Try the next candidate without logging the URL or server receipt. + } + } + renderStatus('The link did not match an allowlisted route.', true); +} + +async function recoverDeferred(): Promise { + try { + const handoff = await getPendingRoute(); + if (!handoff) { + renderStatus('No one-time deferred route is available.'); + return; + } + await renderNavigation(await controller.deferred(handoff)); + } catch { + renderStatus('Deferred recovery failed closed.', true); + } +} + +async function start(): Promise { + renderStatus('Waiting for a direct or deferred deep link.'); + await onOpenUrl((urls) => { + void handleDirectUrls(urls); + }); + const coldStartURLs = await getCurrent(); + if (coldStartURLs?.length) await handleDirectUrls(coldStartURLs); +} + +void start(); diff --git a/tauri/src/mobile-v1.ts b/tauri/src/mobile-v1.ts new file mode 100644 index 0000000..66d3510 --- /dev/null +++ b/tauri/src/mobile-v1.ts @@ -0,0 +1,124 @@ +import {ZQ_CONFIG, type MobilePlatform} from './config'; +import {validateDirectMobileLink} from './routing'; +import type {DeferredHandoff, Delivery, RouteAcknowledgement} from './types'; + +export type FetchLike = typeof fetch; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseDelivery(value: unknown): Delivery { + if (!isRecord(value) || value.success !== true) { + throw new Error('Unsuccessful ZipQuantum delivery'); + } + if (value.delivery !== 'deep_link' && value.delivery !== 'deferred_deep_link') { + throw new Error('Invalid ZipQuantum delivery type'); + } + if (!isRecord(value.link) || typeof value.link.url !== 'string') { + throw new Error('Invalid ZipQuantum link payload'); + } + if (value.route_ack !== undefined) { + const acknowledgement = value.route_ack; + if (!isRecord(acknowledgement) || typeof acknowledgement.receipt !== 'string' || + acknowledgement.receipt.length === 0 || typeof acknowledgement.endpoint !== 'string' || + typeof acknowledgement.expires_in !== 'number' || acknowledgement.expires_in < 1) { + throw new Error('Invalid ZipQuantum route acknowledgement'); + } + } + return value as Delivery; +} + +function appBinding(platform: MobilePlatform): Record { + return platform === 'iOS' + ? {bundle_id: ZQ_CONFIG.appIdentifiers.iOS} + : {package_name: ZQ_CONFIG.appIdentifiers.Android}; +} + +function operationalContext(platform: MobilePlatform): Record { + return { + platform, + os_name: platform, + language: Intl.DateTimeFormat().resolvedOptions().locale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + tracking_consent: false, + consent_version: ZQ_CONFIG.consentVersion, + }; +} + +async function postJSON( + fetcher: FetchLike, + url: string, + body: Record, +): Promise { + const response = await fetcher(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', Accept: 'application/json'}, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`ZipQuantum HTTP ${response.status}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : {}; +} + +export async function resolveDirectLink( + value: string, + platform: MobilePlatform, + fetcher: FetchLike = fetch, +): Promise { + const direct = validateDirectMobileLink(value); + const response = await postJSON(fetcher, `${ZQ_CONFIG.apiBaseURL}/api/mobile/v1/links/resolve`, { + ...operationalContext(platform), + ...appBinding(platform), + ...direct, + }); + return parseDelivery(response); +} + +export async function recoverDeferredLink( + handoff: DeferredHandoff, + platform: MobilePlatform, + fetcher: FetchLike = fetch, +): Promise { + if (!ZQ_CONFIG.allowedLinkHosts.includes(handoff.host)) { + throw new Error('Unconfigured deferred-link host'); + } + if (!/^[A-Za-z0-9._~-]{1,512}$/.test(handoff.token)) { + throw new Error('Malformed deferred-link token'); + } + const endpoint = platform === 'iOS' + ? '/api/mobile/v1/deferred/ios/recover' + : '/api/mobile/v1/deferred/recover'; + const response = await postJSON(fetcher, `${ZQ_CONFIG.apiBaseURL}${endpoint}`, { + ...operationalContext(platform), + ...appBinding(platform), + token: handoff.token, + host: handoff.host, + }); + return parseDelivery(response); +} + +export function trustedAcknowledgementURL(endpoint: string): string { + const base = new URL(ZQ_CONFIG.apiBaseURL); + const resolved = new URL(endpoint, base); + if (resolved.protocol !== 'https:' || resolved.host !== base.host) { + throw new Error('Untrusted route acknowledgement endpoint'); + } + return resolved.toString(); +} + +export async function acknowledgeRouteOpened( + acknowledgement: RouteAcknowledgement, + host: string, + platform: MobilePlatform, + fetcher: FetchLike = fetch, +): Promise { + await postJSON(fetcher, trustedAcknowledgementURL(acknowledgement.endpoint), { + receipt: acknowledgement.receipt, + host, + platform, + ...appBinding(platform), + }); +} diff --git a/tauri/src/routing.test.ts b/tauri/src/routing.test.ts new file mode 100644 index 0000000..19900c5 --- /dev/null +++ b/tauri/src/routing.test.ts @@ -0,0 +1,39 @@ +import {describe, expect, it} from 'vitest'; +import {parseDeliveryRoute, parseDesktopRoute, validateDirectMobileLink} from './routing'; + +describe('allowlisted route parser', () => { + it('accepts an allowlisted desktop direct route', () => { + expect(parseDesktopRoute('zq-example://product/example-42')).toEqual({ + kind: 'product', + identifier: 'example-42', + }); + }); + + it('rejects unknown and malformed desktop routes', () => { + expect(() => parseDesktopRoute('zq-example://admin/example-42')).toThrow(/Unknown/); + expect(() => parseDesktopRoute('other://product/example-42')).toThrow(/Unconfigured/); + expect(() => parseDesktopRoute('zq-example://product/../../secret')).toThrow(/Unknown/); + }); + + it('accepts only the configured mobile link host', () => { + expect(validateDirectMobileLink('https://links.example.com/open/abc_123')).toMatchObject({ + host: 'links.example.com', + reference: 'abc_123', + }); + expect(() => validateDirectMobileLink('https://attacker.example/open/abc')).toThrow(/Unconfigured/); + }); + + it('parses only allowlisted server destinations', () => { + expect(parseDeliveryRoute({ + success: true, + delivery: 'deep_link', + link: {url: 'https://links.example.com/a', destination_url: 'https://app.example.com/invite/team_1'}, + })).toEqual({kind: 'invite', identifier: 'team_1'}); + + expect(() => parseDeliveryRoute({ + success: true, + delivery: 'deep_link', + link: {url: 'https://links.example.com/a', destination_url: 'https://attacker.example/invite/team_1'}, + })).toThrow(/Unconfigured/); + }); +}); diff --git a/tauri/src/routing.ts b/tauri/src/routing.ts new file mode 100644 index 0000000..5dda48f --- /dev/null +++ b/tauri/src/routing.ts @@ -0,0 +1,65 @@ +import {ZQ_CONFIG} from './config'; +import type {AppRoute, Delivery} from './types'; + +const ROUTES = new Map([ + ['campaign', 'campaign'], + ['invite', 'invite'], + ['product', 'product'], +]); + +function parseIdentifier(value: string | undefined): string { + if (!value || !/^[A-Za-z0-9_-]{1,80}$/.test(value)) { + throw new Error('Unknown or malformed application route'); + } + return value; +} + +function routeFromSegments(segments: string[]): AppRoute { + const kind = ROUTES.get(segments[0] ?? ''); + if (!kind || segments.length !== 2) { + throw new Error('Unknown or malformed application route'); + } + return {kind, identifier: parseIdentifier(segments[1])}; +} + +export function parseDesktopRoute(value: string): AppRoute { + if (/(?:^|\/)\.{1,2}(?:\/|$)/.test(value)) { + throw new Error('Unknown or malformed application route'); + } + const url = new URL(value); + if (url.protocol !== ZQ_CONFIG.desktopScheme || url.username || url.password) { + throw new Error('Unconfigured desktop deep link'); + } + const segments = [url.hostname, ...url.pathname.split('/')].filter(Boolean); + return routeFromSegments(segments); +} + +export function parseDeliveryRoute(delivery: Delivery): AppRoute { + const destination = new URL(delivery.link.destination_url ?? delivery.link.url); + if (destination.protocol !== 'https:' || destination.host !== ZQ_CONFIG.allowedDestinationHost) { + throw new Error('Unconfigured application destination'); + } + return routeFromSegments(destination.pathname.split('/').filter(Boolean)); +} + +export function validateDirectMobileLink(value: string): { + host: string; + reference: string; + url: string; + parameters: Record; +} { + const url = new URL(value); + if (url.protocol !== 'https:' || !ZQ_CONFIG.allowedLinkHosts.includes(url.host)) { + throw new Error('Unconfigured Universal Link or App Link'); + } + const reference = url.pathname.split('/').filter(Boolean).at(-1); + if (!reference || !/^[A-Za-z0-9_-]{1,128}$/.test(reference)) { + throw new Error('Missing or malformed link reference'); + } + return { + host: url.host, + reference, + url: url.toString(), + parameters: Object.fromEntries(url.searchParams), + }; +} diff --git a/tauri/src/style.css b/tauri/src/style.css new file mode 100644 index 0000000..6501bd5 --- /dev/null +++ b/tauri/src/style.css @@ -0,0 +1,31 @@ +:root { + color: #e9f6f0; + background: #06140f; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} + +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; } +#app { min-height: 100vh; display: grid; place-items: center; padding: 24px; } +.card { + width: min(560px, 100%); + padding: 32px; + border: 1px solid #2f5f4e; + border-radius: 22px; + background: #0b2018; + box-shadow: 0 24px 80px rgb(0 0 0 / 35%); +} +.eyebrow { color: #83ff2b; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; } +h1 { margin: 12px 0; font-size: clamp(1.8rem, 6vw, 3rem); } +.status, .route-id, .privacy { color: #afd0c2; line-height: 1.6; } +.error { color: #ffb4ab; } +button { + margin: 18px 0; + padding: 12px 18px; + color: #06140f; + background: #83ff2b; + border: 0; + border-radius: 999px; + font-weight: 700; +} +.privacy { margin-bottom: 0; font-size: 0.85rem; } diff --git a/tauri/src/types.ts b/tauri/src/types.ts new file mode 100644 index 0000000..5a52ad0 --- /dev/null +++ b/tauri/src/types.ts @@ -0,0 +1,37 @@ +export type RouteAcknowledgement = { + receipt: string; + expires_in: number; + endpoint: string; +}; + +export type ResolvedLink = { + url: string; + code?: string; + host?: string; + destination_url?: string; + fallback_url?: string; + parameters?: Record | unknown[]; +}; + +export type Delivery = { + success: true; + delivery: 'deep_link' | 'deferred_deep_link'; + session_key?: string; + link: ResolvedLink; + route_ack?: RouteAcknowledgement; +}; + +export type DeferredHandoff = { + token: string; + host: string; +}; + +export type AppRoute = { + kind: 'campaign' | 'invite' | 'product'; + identifier: string; +}; + +export type PendingNavigation = { + route: AppRoute; + acknowledgeAfterRender: () => Promise; +}; diff --git a/tauri/src/zq-deferred.ts b/tauri/src/zq-deferred.ts new file mode 100644 index 0000000..6c6cc59 --- /dev/null +++ b/tauri/src/zq-deferred.ts @@ -0,0 +1,9 @@ +import {invoke} from '@tauri-apps/api/core'; +import type {DeferredHandoff} from './types'; + +export async function getPendingRoute(): Promise { + const response = await invoke<{handoff?: DeferredHandoff | null}>( + 'plugin:zq-deferred|get_pending_route', + ); + return response.handoff ?? null; +} diff --git a/tauri/tsconfig.json b/tauri/tsconfig.json new file mode 100644 index 0000000..f29af32 --- /dev/null +++ b/tauri/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/tauri/vite.config.ts b/tauri/vite.config.ts new file mode 100644 index 0000000..214fe10 --- /dev/null +++ b/tauri/vite.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from 'vite'; + +export default defineConfig({ + clearScreen: false, + server: { + strictPort: true, + port: 1420, + }, +}); From 5b47994cc97ae0980e104dea243b191877db4682 Mon Sep 17 00:00:00 2001 From: Proxiweb <208025616+ProxiwebLabs@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:39:49 +0100 Subject: [PATCH 2/4] fix: make Tauri desktop CI buildable --- tauri/src-tauri/Cargo.toml | 2 +- tauri/src-tauri/icons/icon.png | Bin 0 -> 29515 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tauri/src-tauri/icons/icon.png diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index d142d56..d21ceca 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -19,5 +19,5 @@ tauri-plugin-deep-link = "2.4.10" tauri-plugin-os = "2.3.2" tauri-plugin-zq-deferred = { path = "../plugins/deferred-link" } -[target.'cfg(desktop)'.dependencies] +[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-single-instance = { version = "2.4.4", features = ["deep-link"] } diff --git a/tauri/src-tauri/icons/icon.png b/tauri/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d16904bfaf7b30662f1f201efe23e04059deafcc GIT binary patch literal 29515 zcmeFY^;a8j*F7BE3$$o)C{WxA6fecy-K`X-I0OhT#a)WEK!H-+-Ca`L3GNWwNxu0! z?|<>G`-iN_WY(;?a;`bH_de0;s&aVPZ?ORY0G@)pv?c(6jQENSz(hwJu6<@75eFo9 zO}Wp2%1O#Y#0jdcq>3Z}P?Lc3Y=MS2$8wc7a0dWh_5Jrj8gePM1_0bP6r?4!eN0bt zz5R6t=3yXEk{k5NuS2C{{n7O1kKReg9p}62cs!X;Xd0-}s1(z{r1}8UhW+p2|BUdzH{pNo@V}Vw|BJvF*0XI|Gc}~5w`M37ZC2m^6%;(~sBXzrWPVMZ!TzR`!{68;8{-~FgEPQvUtk|V;3RPSt zA!O?<@~Hie8W14337HO*!po{HZ*%;ZE$srJ3p18^58yXzyMC!+IVP~EPMLA&)7E2;BI%DOw4fZXeyG*jMt zx)imvL=3zlpWG2>EiSpVRU(V2rY#nmTbHAt&Z~a>a(` zF8Kr%Pn+G%Vz;9cERfP0gcXw20mdQxa+XDqJ=tmQhi|^j7c}+@o)EsnH@o9+{LrR%bk-<5?B0z(79JbF@1Amogj`?^B7GfL* z8xkhp)Ac2!CI(|D$`6^)T`py-m;H5$XpEU%I2sXDTxrl@y!a<7AO|5CKTd z_b+aEZNap5b-%yQD&MzBzYABgVcJi{MnSw@T{U0i z(8U)~m*^p52hpnLO#IUT{hhc1<@o1=ov0|7cDH+Xa*Na-{GCe4VN=4>kx5d)!I6aH zBOhB$MZW= z+Rw?RjPOn{Iu<8Go?kodP49U4niKwBTL6P0S)-l(vr`z2`MG{=#wUEFz(H+=H^fM3 zx?u`MjZVdj6xP?b^KQu(fG`5Lfdare$~K=wK;{QEpbl$4Op;zVOv!*>m8K|cG{GpK z$DjMz^}=est-0-nOYg!GotyFe@Q-@5dN6Sk8I#0cPBw>x!4Vc!G{pLdgONXtd<4pq z{7y(7WXtEz0F~)erTwu82|j9PlOBB7zvl^=7%8pU>HPHZVVOsI8H(FrlTy6hTXg$9 znb$&&S;%iM2wsV(?^<#`asgEH#F%hy;}XGId8tvQ5c#mTAok-YHic{mI z9hf3Un~Xklw9=r{a5}_4<1}TKPJ0b#O8&(yjo%Gs*}RlFnyEe1{|-O<|5m~V*s#yb z7dP%y4w%=x>peAN;8E^`yrySp#{|8FjcL*XL|qiCu3ul8)?x>xZEA|t6!lZ(FxpP$ zUP2iETLum@TK7oZkj)Y-`qFn)OOtiEX}qeH9Inm>Gwt`Q-n3+CM@Hh-l9}J=W8CSR zRrh6MSrs76ZnBNa`3Rf^l^{x7W6m5RK$#qg(n`Zzk@5JA=3DZY*HXKY74l2RKcJBp zce17I8CB!r$73Y=BID)u2T z1CkoFd}+uLcQ^dPmC2Pa09Rahx-Xk8HPd_h@=x>kWNyVEs7g79UkU>M7CdQI7bxZn zy?UIkwsAicag2huuL@)#7Un7o$e95h#}6virBx|r5qG843O^1F*X^$-j-^k5alG0{ z6jg89vImrkX4%C0LjW~4+#!I%SIX&p`>IdS<>KgRjVmJ;y_6qkZAvhIXs(--Md}lb z4b-{+s%)k{$q~|^75{}YZBQbRSx`(})4ACU?ggc~{1V5?>vd#pK-8doB;auJ6brw) zcMQZ92hO~BkAU)gjkD_?O>e2pRZ%|=@Ai=+1x*A*IKAlp8CYc}w0Esut$K+kSQ*Ap zdI`bPmi$g`0%GrJ(5z%6Gjt7*sQ_Cu{Qopun}c=cjgTAvQyz}OhW5_^ZRkX#S6TXC0HwH0N;6|$t8VSPo%c?4Omy-n*9 z%nWHOAxRB_z0}_0O$Pr8q!^2A{h~aU0&J8Pg7QU9FViL;-32@5dm^QjQ++D$%5~ar zd%@@3+G^8NAW=;u2;;W5M>2!2JesJ>;cH*`6}o*+e!56LNb$`7Y-Ie>>;*JJnx6zT z3!EH11&ecnbfjJ|h*5&kCF;6;%D+XbH=iC36=^~8cNZ_VYrl<=lxaexo+xGaeV|G@ zti!Xu?$$E)we7kc$ym5*#;bQnIGSau95HX2wIy>T8`Q*;Gs<-njq?5a%@T}u?pSI6 z%$9SA+@0K-vCxGPi4T|p%p<#spP1xRW=Y?DmdhsA0n{%Nv0BaQ|JdX#kd!{BeT|MM z@fwj69|W3xB^b5WRR(W+!j0?CT=sv>8WS?jz4ov2)QeCYo1mLElUvb+l(Pc}T#yL4 z&&{L!J7wiyhH{SRvtLw*m;4=V^7i1~CD63~v@#M&D4RYSfs>7a|KFAas}$Yg+Ger` z-vchq&f6#ZuQX}LP*I8HN%p{Pc-0VfDmq7Jy3X;!l2H0V4YltT@e{=;&Fseo`0uP- zhyMO=mm3aTd3W3g-?E>tJJ8NIuUc3}%BPDK5+yG`57fehSLvH1uA2Q<$~q`TJ%*1y zA%b?OB^Tf}f0=Pja5jlmK~OPEpX#Mt@}y?LHVWGkwpd9 zH4kT9qvhF(ehsk}5Bc*kDVv)8wy~SP%ud(K2phqP>D^)EAk^T7{k&W{QXg}S`ZPqy zs{VIfPXk~c&A1$}xcU0>W{{u^d%NmXhvCA7*TPm#s2q-+f)e>^)QuQ9bSOS~&_2`b zudyoZ;Q++|SA6fZs%@L3#SBkq??&YIknv(<#BJ|{e5sCW&G-d=cORN13xntR$VaG< z+4dq!hJZl#;(W`ln~;l>ZC>A7W%!c2S{Gs84iYBTINZUvSP_j}!o4-;W6x<47F|Yy zB?n+~lpIlZm-#1MrWR)pbiyFVUww9xLK0vW!`$IP@4aGclT@lb+T*_#f|#KL5LOyy zaaz1&nuhMi=d_`Qbv31wG_FNcG|$)2q@!=3wAv}S1A1qDy5?BljtVjA`=m$scY9@s z+ADsZ5EEQEN}WRdLLhORICg(BIfz4Q?jBTrS6~>@n1vnWHnp(A^vNzV;}L4u^U>!% z(Q&cX;0s3%7l&|tO^e5P(~lTxSqS{`3BJzMBzjRTdObL%Su=?-U1mme#gro+AdYv@ zTe_=x)_1o9zJo2q!l62-qE!r@JCxlJYX6skag8Ulv#zn#$6=|U;$RlxS~W!@-w|H- zo+`h1Z$1>jw{~HbuGBe-`H`i%aJDE+x_5~P1HH;!XVdb!NyOJ%Wg#5d+)Ia6ps&P& z5|c)idTa?r&c}WdlywO!kJ9E=zlB7V!joL4*X!MXQyRWA;+wW!xtMF=TuG=(q8eHl zjem1|O>9%~@+u2w$kBz3L$`}gXWKbBicE}jvP_o7vLNJp0=|}i;R_7If5ejj6*Cy+ zCvu-r;xUK(MXZD=&n4W;y;NJPFX4rh`>`tjxte`NLP0fG| zr?STjIe}lW{ldGI;Z?y}aT-*gLhKMy*7PKo<%V?fuE{ihiiI&)C`KLm>d)=LVJfhg zMZV0#!Z!ZWHNf~ebTGDd+Xp1oE(tb6AFf^0w}X^NijqI1N5^28fewG|o@M0K6oYgH zEA}O5fS>&EsI3>I5dM^cu$BO|QhK99xzfOagn}=>Fy2B~HsNRF?3`Gg%I|OaFfqv% zUx@@bJ2vk_3@-2wTWa$9X)-w?RZnjg3e1c0S2<(u;?m6;Kcwh7u*&Ba0gD~4X?F0a z9;5)~qnj}eKd1eGQNHx%_jJmx0X`i^NFYC$fgx!w41;z9rTe?C4!u>PTDb zF3UMrrU~$F_?~=b=eSWqp+W6vxX`8J2!HX4{0kfAG{Uib|6U(!9>;*kCnT(3q6STN zg*KOC(V-alBTZB*OF3OwQEkp?a@UJeLkSI;ArX*3E6ZLHSPb!)f}`dycKuH6^-_a@ zK+wzDqrvR1=0N{;P3`9FT&FcxjkcZO}`;Xl6b)^J@=U6FDBz z#FER-q2Q+{${v4q#4=jyKi+B6A*|(L7y<1)*THvG4Lsi|k`yIyOI}EBRD5Laz21@X;&u*Vp4lweo>SQv<&aCc@(%{9}u1-l@f1P2jI{;CDULO1GP7;W@aI zi+R?n;2VGEvz;{bTOak5sNa4N#cYx<7N%ML&=VECy*t9|@`xG)ql$Pc95h?}9-J47 zF$!Vl3I>GQ&ZbqMgVH$@O}ij=ZffH+6x~U0Snw z)_~l;#}9KkG#FdJu!ZBE9rY74)k{}J1R2AHVYK&f=Bd+8{}L{I=?cBEOD1~@Zz$RN zc`|N7eg%_eg&}$ zt#s2gS1dt;pUp#(PplcuBp4J zoF38rR>Ao8Giif+KOoob8m>I0m7MF+iC%RO~pWM97K|CIaeXtr@P0JofwLtLP;`9hWsl1!(y{|6?Ci2Y+IgCz;WQgl6NbesN28Q8W{a0yiVU$ zw0ceI2A*s^1&DM+z!)OOQkUx9`N@QwtTll_;fO}U=6xfw`B7ya$ulWAmGj7#Wk>vp z51K;c)vH9{#aH6my?g65KmQqx^;w_K@40PgpQSXKH?=j$S=m@TVv^V1P}rs4)=G22 zX`+?23iHW0ceiTJB(%kiwpV^P-*pna;mT}n){PaHCW`IQWA@SI-g4>U>#t>}cR|7h z*%9;ZZUr*O>4`HlG`-Ls^Sb392{OwnXD15rHbuc`3-c>B0=VaUuFjBlAA#IpQ2E<4 zFSq*xjhIVNvAqlg@*)eH*;8R;rBjtv&HHMAjDps!S7CIxAFsbb-QO2)(x!}ivfjpQ zbP?F)0uOBYbdQAS1_{3x13vynC%M|!&(_n8V@ba1eu0u#Ud4bpE~TIC2kv9=RVi*H!x`UNKRh|b-3+|w@( zo;>fsHg@@v@@{)v66CN$g?eleP2;uM#@T(pSFE4&$+ZU1wc?0E%|0UHcarIG2r+_P zU0Y=#Z>V`gI6ER?^wDfu<9z2$k!Mp~50eRZ7Vp$B>15(8nE{i-ED9p+#@Qtu7KL`> zxu7PY*}d_k27Jbug1s8rUsOX6ZULngtnX=n9UqJyzP~f+eMva+DvKl?E2uCV(PJ}9 zPe@i&+-;Wlkt9vh&TBYyr>*W^$0F@_ikCze8NdMh)byy6a3B|aakqdqLlAW`G>dBd z!vDHK<=RiNERm(SYPT?u3-Z@b>dVO)gHb}osJDOE(1;eZ4Xl>&^@X!1U*`iMK!}7G zFg#>db{3i3=v?8{9m)aX8FE41hzE`F?JBN32EK#)(r>AV2p$CyBlC+_IEcJ{{aIe4 za1kr0u;gy6eoaCtHw?(gOSg*r@A1#iM;*oCoVre45gMDxme;&$2|U_zZ4G)6SjGEU zdNn?)0k;6f5aFWk#btl!sl5?1691f6PGInUx2(3fXiKia`XH=E0FHkLe=_^PT?kih z!SwBrC4Q)JkTYoe7FtZfdDjEHw9abUIjk)%o)wwf&ER3Maq(;IN)IE@^my-u3>a}2 zt^lh2VSFC=weL^{==KYEcY0X$8BjR?Otdo{nfR#_{?^sKBy4O*0pBp0I-zcVQvX#@ z9C>stcR zx+o9aDT?N`Uj4d)?vwfUG=zxKs(W~oH(-5rJkGm1)#X0y8?X7SEB{%c^+|o%>-?Or zAUWg$jTGjQZViUNvAF^=^N#vlC0X^61e&NUy2u+jFr#l@E79O-YB)`+@ZGr$U9Hd< zQ2<#5Ns;!#wzh5|!kc}qjhp?Ogf+c#7r*XLehiSGe#Mo7B_cd_S3b5V-)!F-_nMH{ zxoyp2;L}8rI)A~>Mkz>QU+07Scwyg0@1m)SVAuW@ek@;igaYO##~LARarrri1&2>S z**{ZF&Eci+H2gPbsj3v~DZYnN?D)vK{6vix82Z%S&;8$o+^$P~QQWk6s{#J((OIB)w-evtgkSPl_0qAeK#UipjfQoKD8wYF&nlS6(@7#|u(oK}9yZ#J{q zf?g6k7sc)R{UY7zic5v*m+5aWE7R`oVgcYQ;c2*83{E$?AZ7%RnMYe(6(_AqFk(6I z*(xdcpF|U=A+B?OFS182_y_2~!c2HGzcE7p188E0n^@RS%ugk=vy>7T>YsjF`dr=G z9=mv3_>MA%149jZ(e&Ja<8`fMQn!qJUoFOsJ->!Q3Uv{BdHD?LgJ%p4DQFu0aRPYI zA)quXEu1N~Ng52a;BcO3EE2PzFzIlt_{imL#RZHgX4(dv?7LKY^I9#jcT1pEiicBZjEty<_a@c)KhVKzOtqZ zZGKyk#4$R~)?sG8BI_o^7RT;)#^Au>%lUfDv7|}T&Y$WfZ}YDHl6M9H$j43Z?OR@` z6`BE}j!`{5AB@g{9f&)?WQ|i1cV}+Fz__xGZgGZY>aSJzJ4N*QU4{CHpl17ObkB-~ zBrV_dYx+CN%-sFjtqn>J_fxWwND}=XXyKh&ip*i5^?f6>cyE;tTls57s_``P#! zshnxlE;?#@VG&Zc*`*+^>i0>w0%S&VbW@VQ%*qU=uJeg>AogI&ME*3C&~bYlcvGxh z`jRKzuGgoy^Tdx+oEq5Fn(9}Q-Tw4Y(in`NjJjJNjV~N6Al9akWe8ogZ5RFzL76`e zcscpB)b3OcUkjJ|`}Pl0MQ4T^O*@-QrwM;u=lw${7sY61S;|iL&u5^MqrvY$aV9#+ z)@vvmRPb?pGWhm{i?Z z2cSgG$iA=D?78tDG5kMBDBRTf4UU{u3#G=TU?bDf@;TN6Pmr|}DnraZuOU>&ssnu% z=MDub_2P;r9~EAt9X=AB!fgH6E~J~FSFSVumtr!yqDA8A*5OUrM&|dF2c?YR5{Q2> zB9`LmneCUgkp`H@q3WEGZB+HoFvfcuhPl{LV|Pr$VZ@MbWqRIjSgqG5#wEd)|G~wc<>ysjkOsrfWK2D?K=5ba zXCKSHQlP2dJS<(Sqf)vBR%x^g(=$soz}fpDX9)>LnRfal;d?)!$Kfe(hOr*$(Cxkb zfSttqZ4VbdNAik7z$M*9q&^KK`WTcuQ%*7Yuz&$gO1OTi^=)%On`@*JVFxmK4N0$Y zc}QG5Rt?dlGBZF@VSSM#%avK@@t@e|9M!>^K={)|B8OO_f?(M5?Q#T;7=4o({IhJ=hf+azA$RJDUu414@*3@kG z&0p2X^s5c!;CNk5&VvjKr*7of{X_pD`8uq#-MakM!v;t_MEByegq||FbVt}Jhh3BW zE-1WiUmmEBzyxt7k2f{rKWKgD6sdF;xOqtRyC`ECo}3!}1wAMJAjbf#FT%_t=pF%3a(kVtG?VDzOra+ZiubpX+fpb{RD>%2WXZYX4J|;wDkYuSxAPY4fZn) zph51i(@kKpNBve7WVaZ>^SXDj$`{de5k5z`M}|hD$$IW zw_NJ&SGpBOMhJH4A;H6r+i&pwTj!0KhLRx!Y`rs}XtVpMM3%%pAhI5gz5KBRY*KpA z;sSi+SxG@IEBOXVKb9wESYZbjkdW0O0CUYjwS2>`g?2$y{57Wn0&SNjq4Q(FxsbBd z0gW*yR!p1a_9eHWI<+t5X7K|cTt{e}O*>i^_QxVi*PSA)^ecwC_>A{o>xOPt7vruS z36GgWe*)B1bHz9v68cB4*E))CvRO5mADqjw?!8&wyhwD`H`=qaF-9n^eK2!|bv}Kz z?k6;B6ej~3@l~Od4hk;Pnw4!~5zHc?{X*3ulyJgnmFf(SKX?FS_UE7@glhE|Z4jcI1I3Uq*CgBNX zEpa~#_+)N4BrY-5A#6FJ0=y-gHG{dqO*)U;r@^$}^WK2x>+JtAf%6!L)bgqWm|Jh5 zZ1BLXpC{WMW!OZ>`@r1-c}^Gy{jZ~OG4ifVKr&`eXec%)1#gZjeqM)pSS zcr=dt2x+MI8@FI=S$b}p_YXO=AD;r|9_4!Gr$E5_PDil1Yp|qj3(tWm8VTl5m#F&m zdcu^)IZw!##Q7iS)6Vg4T^fRn)UP7?>l@Kllq(QfAFeYtX&sATAok{yVjA|cszE+6 zCbHwq8mONe-X^ohVajgf&`}to2rbd;GA0rQ&q-8R9kyLzTRNjBP;r3u-j@%J8V=!* z02`f?+RS_mWdgn5M9NT=paO1JA?}tjr~OSn@UP_c4Os|YAvQIx(^T>BfJ`!A*TciO zZ~iP&@eTj8JW#|~?ukqXsB~ZXJB9{LmWBu2G+-mT`zuXESMl4xziW{`Jzl+p_Rnnn z&l*R+(^HwsxJJ2*vK7W*cD2x1FHkv=6G622*I7)8pIK8_kJG(h89ccgQJgPl?k+vs z@*QUr7ec@R7g9Fy6Avv6^Es>c+e&tMJiQk{2|Oz8Gq5Ap^&|NAJN zMel!t-o|7(Lcl@P@KaqfYsx8sgA+OBw@{GxSx<5!I!Cs_M*0HQ7iv9F=fyhQkl>vt zRf1f*#o!W7W?Lf9U_ej1yJ;wm_t_Ey)zfY5#E%fDATF$MAVy(B~hEM&3%}_Ls7}Iqe_-=wpBUdM*D@JLfGhkTl~?q)$uz4;{Y)=9Vtd zu;pTl+O{d?1J`p39QTUdnAQi!EgW{`C9u^{eSr7~9e<^~lYu9sE?AYQvdr3)>fc<( zecxEQp?;{`e&&BMkdaSIa2VUl{EFCOKNzpNOi$f@)rv!?67h>_hrSSW9Y>iiF7xBM zX0VyJ?HO9+oXQIOWk+_c`NL}=gCN>vhj0X6mPkb~^)gIY?E(MzW5uQR5)Bb5@s%tn z$Zw1wlb-hP%b6k!T#b(r`1E;!%QlVlc;A5^C#;-*OOUin595VgGN5!NQL1wdOTy%I z%qOZiYFJyJl@%ejf-fLH1sZB8d^tVjI!<63MpP0zvrE7B_qqt8n5qzj=dpgR2lXSL ztm@qHLW)of>2mhPi@hU`Cc#1YuAi$@_851w`@e++d+o*o@n+H_%X=FtG&I;6FhDv^ z5waNg8bBf(OP4kIla6vhxzZ=h}FA@6+j8H6cQ9E$H)u03$hr z*6{Ci&;{s^av9|s>?3}N4Ba~vkjnt%3qjw3+IN%zLqN{Q6gmZr{jr17isFgoKbO8o zDVNT@9O;9N9`? zf(eL!p$l`Tck<^6_h828%+^o;=qo+X8>={!HnR(P+=?(|uJ%dBU$)2mS7s8voUYby z#C)Hc$&taSc*Cw-&B&e3YI9nBW6SS&lg*!GBCI%YP@uCgxrAGyr^ZhAnZBU+#3!j4 z^|)oCIH%aTX}SGn9}>Voak;@6z5<`wGkZX8xh7k`_3Qg z>E&RShvVvO|0W3NdxxUL5gA8gg$@l~ zFdct3+IxCNI}K)`I@x!vN#g2%JwN;;N^Y=nbz4G{0u^p(I(U2z0ZM$E$NfMH zRHhIkxoCw2w8GbXF@*3FQwruu@%)GGIvKnqxZieZWjEOAl=gt+5^RRwbuiUSEVEo? z*Kg!8$bMN~6}rvY5DD3kjuHS?Eet+lyP$Q7U|xq}>_t!_1HXZtM9v8N+;$auI|g4* z*p5D;z(V39y(C{~O{sm4`QgC!u0uYr{ffqH3^s*!TF5We8X|z4Dzphz93etT%%9gy z&c=c#YhH+lCIoekV*R}Ae5-QuC6dF(LRpR)k7paZ5Z?+oxAts^EHm6}wHVDM-FosW ziNtVUn9Z{uU$E<~|KByP!oru<5pScxUQ-H*B(u9$H3gkpxFN0^XV0Nt>^-@7dyX}e z&DcpNN-sb$E~6Ith4Fis}U2SuD+|t%;a@0GbDO3xDy5 z5-+4s7j&ZW_{GiC;)MSp9$#t0c451)c6WO5P1zfx9Fb#)+pOn7_s8Cj8E4_A8ex=oP6y3HmKc06hGXu=}Hx z9a*gUd0qIeKkpV^R?EhM=nyw*twufsp9183yi+%;1sW_R&CA%)v4eTBWB` zG7X_(7wj#)*c9Acp0p48_me)`XFYk~izqjCujWt3K`D5`gPVtJ_{fyiP*DIhPw4y4 zW9VZ-oV|ccm&a@^$IZ;ER@1t{{ftL6k`CQwIR}3G_ZRk}TsuA8m!%eO1`VFW?^?RI zyUpSIhjJ78wExaD3rt-z`udV)F3ep64^|~b{2;@B6XK`MDMMCzzr*WXY3^pGtW*nI z#GY=Kc!i}OF=)2&S*a6yzKzA7tOV_o)UQl8>zi>~{kyn;ujKoVhrFA?-+{Pe@Ms&M zMfh^pQbGj7gICc_^MZPjk+PE-)<^dEd%)gZ*MIBJrgV;X-*gU->u(m`Rm7pl&{xt# z`xtsd?I9^(Lw~29xKi)Ei<1EHpW1CB`y>ZGblm)**S*BYv$e;Q^zs^V90Yz&z}sp& z5#;nApQt83q3P&ednK3#+GP1SMQNajSmV z))j^;vGqejj+ScmD6ePY|HABcf^Y%nd+uj_&wq|qrD*RgaW=78EfxFbss_7eFCoJv zBlrsNia6_$PM+Eeea)od3A0QO_0Y)f#*KLNV&#e^j+M2tnQ&5zt}lJ)52c5s;(cVU zsvfTnK8ZU#G{imgNMLRU+b9~Wmq@^OgWH_2rUr!nwuexX3_r!oL&pU$eL8l_wcWoTB$6u7Ts`J zAPaX#P`6m~*U*H}g@r#lsaSj^4-k$<&kyl&x36w{ee$IQL}PD5GTbHGHhIL1Vd<#y3O!_v&v)s45 zde_qeqxObINZEO6Uwn+;u5A-urB~%h{-%Q^a$ss~_otxQt?x|jEL31jB`6Iw3Ushq zl|X&9>3F)+7TL5?8n|5LewrzSq@mfc9!c^h)t3t;KA4gUa&Tw$vNu7(WsNM+tm@}% z?HyLlHLfF~_rsD7Q2pXK9(;JYoqd_!wQjs+fA3If(4ydq=q@w#jT~G!IYnpCp&CqXA7WpX(CYpEabP^R6w4sgu zj8`*}BlA`dN=}BUJyCe%&D^auTpk-4h9KEbg!I38kX!#@@=H9odPIvFvPKgA@Y0Wh z(eQlB8DOO4#?95dO*3h>vD)MYP8N=6w&0mXIBhQ|OEtiZ4=ztBXu3AX?a*^y6-1eXHiiUZnf8>1 zo!RC;9ua*o?01;S53CkFqs9z7Mo2BAtIbiq$|Qt>E$yw(7d6srrcgrum{d`_iS)~w zQ{00h9Q+=iThMqX^&>F7uguQ4vMQWtxgqfOPRBd0?J@l3z`yd{$!#0k41vh9ujosv zyYFyoxZ*(1liqm+UVTDI*VxV=K+jr zR`AnUrpE2MIf*PsT?eK=0|@fe0p4zJ61&RuuK>SqCv2OUaRSFXDzw#`KmIzwfGUn2 zJ*$lv#}N4a@8E*%DhVOfGSHh0uibRC>H>Qw;n=)oMti;K;P|?xGR%;U+o%u69 z$_|EOX=e3i-Y?T{_`|$X9B{oy`RQ%m5?)?)JgEGr>p$yRzq5H__uaUF_ zO=x>?mbaOxqAjT(Ew@ldLaN&d*;xOogrWkL{Sq5}xd|-6X$j+RxykgeTM4)g5RzGg zHj56p7OTK*$%-4;oG^9eb{r2u*QeRCNiU|V>{RabY5Wp}s+b0l3&rueEnVuMu{U;)oi5`$|0EEOd>2V2({XW)?rj_PU| z^6PFbSLhh{xC3l3EP3vIu*RmO$>I}B&>y{H-q=ffz~}RF_XdAuPd-lxl!rZqfVwr2 zwn%Z1pmHyS4jXf2&FVX70I$=&+pb;9K-=Scwz3PyR&nXaldF~oCsh-?3alVnD}Tlq zW>MPDzmM3C_;imKMAn+Ty1eW&wdWo)Vz5%CHSC{thEq*pMk~=(;NKCo9uS`;FaI=3 z;atxBA|L63J<=jU8Sc!MP?XNn1Nnkkwz)FK80@8C`uAsbBomJBk_M`eg?KDK$%SZ$ zHMYrgA_2`3r}|cTaK{==!rM++8$|{=ZG^A@WQ*cb7`BI$p^^pUUdkVuKn0s}l27)* zPb7zyS8uNc>~57~a$dEonMwvssd9+akMa89qu;e12X*d+Oz@@^4E28{oPy(c6p`Y( z-GhoFP=k{?kB9vNiB^@YRVW3W{xlGVVag72^>|Wyz>!z#Xp^o8QZngVH(@7(_zRIA zF;-J@EZ?}{cx?g?;xg*YLSb+^suz56L1Fj1-d@0%DbKH_*l3tT&D9~>-hEA-X~7T@ z{X@KM9amL9+^kf4eh=1Riw9I|*KhIP<3WjO$WND*s2_i=-NtQShK6s5T+(feE(5{m z(yME%K*($3U}{k+Bi4JZm_Ai=WtIl+a16ii-%(_A0XZVxx_Ddmhi!-M(Xo#-(ek({ z_H4a7$9O+^4+3Mb}cTKlevA} z$#J{5Y+SNRA;WDKaAvcT4puS2*_59~Mxz@X6UFd7E)}BdrE3G&*cXTcf|0adoQJaG zxm^dB)$ZzNpGNs`gs$CGk6ZIQhr?y#SR0>T?T@l8)?>8sE|;_?*w{=2ZG!3U?TEZmZ1A}3~Y>7gi~N0N+tANB3MtHNzZufuEd8w?L(4tz2z~QRqR)Tv>-(so^3ciHw=cliD&iQ(L)UY65)?rcdp417sdbLzJgPu76~8&?D$ z^?@&nbJ)CJMvMI03N5}S663SD)Xm}}pqI&;tlDCm`~C~k*oE#dqM2i_UMcp!$SGR9 zqxpOmoYOr_v_b-TR`=SrkO4)0;~FJW|`NU-mbIbL__G#h*>N6FNP$eq7@Kb+()9aG%V32j}6=<#y<)`ZAjoehyI+ z&v|I-zI(aZ1gf~AbHb(b5|?5~jLrK>Kq~=5*gv-9lZXL8Rp)fC<3CE)&o1m&CQeie zR(APujSa>s-d%d?80>t~x;-?FVTx;M76M%fUtzVZhV+F2#jzyMG-seHYnWLa$d`Y5 z_o9V3T)+CyC$ljJ$tMG`Uog4vI1qeXZ(aW!@$?IFL43x zlSsKyf{K!gdgFAKVd*ozmxkQnXlY)3-OOSRS5+q*ANB|}vJ4Id>{#j1Y@NN^rl77M zu)$QTB^cQJB3FJfv5Uij5AlaI47& z&9Pbnx>sD%=?}N;LsQ3dT@6R`OJT!(E~p$={lI#$Z+NaMr2Q`mpIXp40>+lxkME9x z`bEiFNUXy#k0SgDsj$2V0wQ&p#lZ@lp`PQ!H%k69aHSdNRC7c50%toPoIS_ zcX}~CQSSB5hhg<7T79M2uw(pEnvl<%7UXV2)*h-iO7|WVL*5+`?{d+5wiq+ zu?JC%F9`M7ViIo13wv+naSibXMLAK$w!o?c!j`ASt*IS((tEy zi?9V=T>>#2AFQ_^yT|o<)(4I0wY$w+^mq254Kw^l3k}`18(a@JkDzg&7x_QE-xEI^ z#-h}#jf?`Zg@NP6^cNVixD<_!yn1xT!HYe(3To>ABrOzH5#s=3lo=CiCs1_)v2~aJ zhtw9KTs}dYh_@e%NsZ@R_rJ(~w9Qk)TV8|LG#mvxtKu7vg{MDeZO(wLGTJm!m@?ZM zX}gEbqv>VaNpZKPTLT{_^wOJ|$m&@{nGIG4eCCkvB-|ioiUqWnluEKLfdxg-b?fp_ zXeRgh@b3A3Tj;c+gJfH~aYM+i{Cmt{qlEhqM?1F$Qvx9|5V^T&$g>+lPxH`d1zfQg zKEF5ZAzA|FFEr8iFX8O(-}~?WHm?GShFbkrO=7A5Rnv;CMDulj+|EwSBM(7&LIJF@ zStm&URWILi4jO*%Qc|dia=}aP8oik9FW3$Ji>_3sk@= zye~dXzehDdKh2ec=&xw^@djdq<*xH}I{L#CN2g6ngO*e=?{DO&^YqJ(BscAE(Y|oDy6zqbZSxO zj)XW$nt>pwB+B=enI2NV-K&)}Bjm1?xD8nFOh7Y6^NjE_^=LoG{^W>C@n*cls^sH1 z(1;4<$d0NQghgWc7TckT&!gIP>dP2g&{h>!zv6N$zrY_{REmM_DC@)fmz%*$*}v$) zQ*A`@^kKwgUy4GGH@KQzI9YPfebg}Xk=dxh&dci_CrFl|`#3F5>Osx+*yiZj4PLI)ZwY0IY>6Ss zTwo{g;U;LhvfiN(n#aP5uD0P$zax?R{ul5y{q|34T*oBuw#7RU-oA`kW@amY4q`Pj zV>ZTS=X3pQWqcmB0g(}lMg`5oOztv-(&&0oU9OQ*Erl-`MEX?{ACYcWRK z@5LmVf@RXg8xXo^;Rc(hMt8i_bS@vIg%Y-$3P3@&}+PU#D&h-D2~VzQw5iO-{gdCJ-OTjNZu_kFZ|FP zF{x%=Mivpagha6qwQ67FA2-;ijk~8%)O@4cV1-{sy3|$iEo>J$RmZujQvYZVI?T() z6Ys44+5UO}y+m2;S2DZl#2hf}`C#%wpSvi9<-ek%}c#r<( zLq_PK%wrCzLmzEhN3U>JR|r5o3Z=Eg#-N{RCqo@KBay{AO{%r-P~YH)LwwAGjadJ$ zWdS8GZwlqzU`|o{`I02@<0=x;E@StiDlZ{-8Id zZ@dOC7{&&ie51)C;q=s_?{JAiTQR{=f_;|?#?15_Y_hJ|Uw=Jq?pcaPpFXHLw)n$l zr;)}6I#PaB9JD#W#DDL$|1lp(9s@o;cX4Md)WZ6JzCW4Q0FWUwD<_Rrel_&|`j;nf z4-&)W5zh3%Q~KW7t*i;a9p?IEWtDIKEhPTcLU@wI-ZRe1edtVy+L$O+a9YJpWrNx6 zG*WO$BHTA#$|AP$}J)}mbFB%3&cdLYgAW9?R071HIjG8by6=@};Wt7zD zloII%1*CI?z;~bDzwvwj0q5Lq=Xq}D`Ml2KdOYq|4uNbr&r9Lf)8B)mW^nW{yVy;X zUgn2}?Bn`_1p$tr*)f^B_;vd-9V)Hmlj-P#8rE|n(%UY0Gu0MfXv;O#@)?MgqtKgI z4hb*i+yoRq$xY_6(|Ox`sc3jUOl&p!TQET1Z=dTOOa$=>(D6PB-`=)&XUH_A{wJ(V zQpJ9(XW?wx&b8h*BgnCgJLI1J-Tn?G@)~@a&hZCS^u1dB{*cl*GVr)RtExX$u3snd zE))GWn_CF*wR?#-sdTlTO?2X4r<);w?p+uhz@3M;m|yaW7!Rs~G$VDpCiz~gU4iei z8^68Ya`5OxZyyImN=Ofi1EWTY$6^lrK#th0< zmL@!1fUh`P4rkaI%I7fy$^UUpYuK=K`rYMk?4^H`y`Qk}F)UKprBlF!$*K|=;ZQIi z_gu|UdDe!}`=rzrn%WY6QdD97R^e{;@^QfCY4#oNzN@4yvh^Q1nWjK`6n8_bzSN8| z1!Ge$A)``#Jq_}zT1Wj?K%MnGMT{O|VEf28|~7`-M^wWkNr z2&_)n=$BBa>`LBtc+LqE2{}CR1Fl%AeO#l)2SJ?GGgEb248FUT2$2}jzx$TGfQ-L) z((`4h^bHS{rbxO@1^o z;8o=vKI$@C68Z;u`R%~wIS?sgBz~;(*K|}r%b(&#@$b1drSMsn#frn7E7zbxc3N94=FRAm`0l=vynR$lH`rW_JB*k&}>8Xws}_W zv>FLcIg7u1%G|LNI*)okl${W%CC(x3bzR;Y{(Qb{&KDQ=kj%_xCdrgzUuFJ{Z0u1q z9NX}cTwBu3WfLSV|DoA0-|O%EyvIkLL-PB@C%^0b`qY$YxDtLx%uGo6!IeRZwms_e zIg7v7-{=dQIHlWRt_(izi2pb8DWpff3GaG6){m}F-xh6DY8$%?oi1(d{;#6ir>97$ zpMCnUnc?LUu4xJ0xE}6%I7IG7%EJz5B1t4TRL7BcJC&Ykk(p~wZ)T(v8_aFh#Z4(3 zS}ZATOZny9w-afIrdZ3p3%fQC~+=JZ_dUzE7tH{)sN#Y$^IPQJh3b84y z9-q2Ax9ovSR4s>6Oj0YHTW(!%gtgnrlRs)YY$a=954xk-c;A@lugf_5E|wAK-5CSY zGkIN_a+l+^`*Y|#xzRW~M*jaww3JU0LR}(h?{b6D1dX4FI?0;(3$j%npWLVVdM`BG z%gS`;YY!oa5GV1i*48CNja*(_I4uyL7d|ddR@;7i!$UbN!YxiJpA_rqAhsZz+Ab`)^n=<=$Cz&jrI9Ibiv8cQ3{`PpVTLXMy01Z z3p!v0=T(XX(6I<;I{2nYzg~B#RDCNm={u8)SN?T4oXlGtVyA|dyvMqy;HR3ftw60P zDcgpN!M0;F<2s350`lJuyLNa+Zo*{BxW{~6kgjFl{X4t9ZTGeQ?*GVRz8AdJg(LGm z{2LzVE$$l%lVvnA=c$%_n;le@$&OVL&@=S;|Gwp4Gg)8s9yd09Y2I(EHzlAuThk{( z%ieo65L>;e9+56)l#?9C6xmR9Q%9%?NKWI7ZgQPG5OSkd2V>KiDhk8jGI?1*__b<) z8riXdeLKw4Yf8T*pGOd>cBS(1O6)fnNy_Pk%rHBN{w+u($tz*-Glf8Agp@R$?ngg% zq_;lc`=^t>&%wtx`a9I+v<{3v@h%g^#`;xMN{CKIA%K1?W203zL_LNZ|L(jjt<44d z7I=Tt2D<|^sIJp1f4^>dK~}74L#bk<{`X7dV*wZ3-5G=q`Os9J6BM(Ud9F`1-zY36fRzMi~2BKoTz>xQ72>(fIuM zai(!hliGLJa$gfkAcJ(Kj1Gb+yeth}R!^36#^;}w!_#+Y0(R6dc zyxqR7TME6=nQy8rh(TNl5`I4*ty$bp{JAvbN?nDxPrgQ{)}~ton4#kZ#9OfuAmCW- zKXSr-^P%`0@IsA#{lTJ(|H?_{OQrq%LLJ_l6#}^c;jUZjN!2VHU>f!#hl;nhyOB-w*u; zm9JKmG7N}k5ncZ?n(h+la~YbYAYs6o5lLDj;l8T)XtDaa!KcBGQ>SLcy>1Q6doF># zFQd2X$kFn1PFuqQ5|U1&%wu;MyEDCVx(p9-;u`kvhQFyVGF9iTwAlqqSGu01>woj; zl7Cs}7*5)@#t+(MT5c1WuOI251TCw(tv7#HS@BoMClr$w(IVE*;uM*qpAwmZWC0@(QGpctj|Y6Hy#q;XHNoN4wPm2Um@{k zNpD-;se~-mcEaWKsiRV3)u~ZUgRp;OOn3p$lqgZemLGk!#wk-Y-&a}Sx{7Y3K*VM* z!lfL?CeNS3IGVyiaw|M^cG(Jrv^`57&Nw3fQzE+7c^dq+zK+%XwELE9BB_&FT z!-VS){{BV2x@w&9=-)3>;rz(HTLF@I34$9lk}N!;DkVq3X8)Rt8? zXhxe9N@_QmiFsF7K5odXoe;J=ZBTqOMlj*?Fm!g~0PPlUk|i_apH#PNlqYG{sc!)%+CxT+ z{Q?!H80Opi1Ef>ZvefZ$BYxL8ivevF$zOR6HoV_Xa=NAz%I&5{jpkLjDe><#xc>US zo6Z)9bD00U>2F3{D2Gu|gtA3b8;7f?M{SRG!S&PL*+mU*TFyPpFw}S*9-j`xIj9zO z{ZR0Ih$NGvTQ~?;a|}KTLEXn3{jpTwH924b@fNAZXU@;UA9AAIsOxv6R2}?RLe@^Q z&e(~Sb^WOKDdXwe1HQ1a^npQ3_Z5XMSaM7j#GoOHc7~jxdV3Poo< zaB<6s&6$%yw(*m-4>O){5iMf*q$^Q5`i`CfhjJU*LtkBk}|5mFY` zYCqBGf8N^b85h^aMC_2(9QoTZ?{K`@45>moghj^04>AP;-(V z3`XqeBrGntCE?^&-=y|0cy1zc!uR;ajw(R@j}~c-^w-E~o3TmKiQrS%wtN68PI0W% z-!n?#7X-6Ats$TAPwV`v`-1ObUYziiKC@lna2MkMIdx%tJY``byIr(yF-`bqT({x* z<*iw+ZEewO=A+OtEZ*#8E=W%`hYRJn z`dPELesXnQzIAk^$Lg{dcTJiEvL(rX zUS{H1T7kQF7RK5C$k~78Bd2uVKlz`UG&wvA>peni_t!I-HCwGvxw8TBx6gV@Wo6s1 z*+Cza3ZM0UECI^0qkr=UE4vE^O|Re?CcVsOvYW01&~-!4H<@t9vB{$={H^YR#v85S z^PY&EQ*3R_vTXp>vhJkgb7RPy2{3bpXtIFc!jK?GYCO~9#6M({1$Y_%a$F&w_GJ4l*_9uOkZ#(O!H{<`Q!T$ zL+0*gYq6&O&$LEZ#%+(MJm`Vqwi9?`Ow+Eo2lazL{vxnj2+fRZ{^klNS|fA zn!i;#4Q_Rg@Vvr^jge`8cE?d24`CjCd-m(jZ?F519W5FQ@w|TbG+VP17#(&IKTzn%uqwi35QA#D}4b{$YFFphD3- z3p}ZZE04-%^5-{K-)#kV^bH5Lzw1~0GWC>CN6q-Q%--w(1uHra{?vBkaOu2@xPL?I z;B3UgB4OHkZLlv@%j0PM^1c(>%2EO(O>L9 zz3QH~+lA3KUECtJCV96@2TxDOmCgMcz$_7wihQ`;K-uU&6_5VosSWAdE!qtFSC zDy3$Aeh?uN_hs}CF+RWAe7yem_&MGmU9*)@mv&m0Y&ps(@Z|C3*78uLJH8_j0Y%r{kcdc9vLq@i8g1+7NLM>w*lTc zcuj1f$C!$CO4$3PSM|W(y?E$_w8>jfv;eKBvDiwm<|F}0l!8F*}-l;=@xfy~PYko@>ky*-=!Obd>pfuR}aTwi)1 zeLh;AS)}aN0>Z+#BE&n9Z|TZ-SEgJG`?`%OLP#_Sxg<0bX4U6ilbJ)oFen_4myt&w zuV@YE8W5p*SCzJ5L5ae@@GCe;5(O-9v~o_3UrU(73{Lit>zz_eN=JnemSJ4*m?QbB z7g=vcdn!-8X!W2;oiky<(Nz5<{*tFn%q4vm`<}%+XRjj2Ix_IFa(h@j?nX6uinz<1 z=It6eky@Jf9A*;kQnRR`m<1DbV1&MdazaB`eH++)Q}0&`asF6n%t z-o&0nZOi!;EANdBT5++r96Z=(LyL7Q6g5zNy$C1C&#osgBIlIGw>1tS*GM>>m6Y4= z2I;lvs;`6Ya;ia;@FR4?^4SEODMtjwr&!pom+gQf54aqd*_sz`ltDL8)V6Uz{0ix= z5Hvac$hLc0UFZALo3N9elz;Dztm`!TP!v%v@jec)^jyrfn!@?ge^Ajn5GgUG;r3SM zigJYe8G`&W!;#iSQ%+yUeu>J-P`*>0FKS$SW#NdIjjO}Wc4TSi_4uXq&ymvQ&o$>u zGijyLgz9}!mV1CdiQ}v47kNBXAT|ZfAE?=%wderY9I}BxoorLyW4DA!27Ov2X1*1g z@nSRPJY07nt38?pDr)5dUiVd5v7v%N8{E z#?3lV4AfCx^5wryNQ;kZ9PJ1taQ-O~QBmf-)60kp&0(VMd1_$5oblvr?dEXv1Lt14 zEJ36KOH@Cw@KD(a;m*NH;kFPfZm#oBd#X_hk>d-kHdK02* z6F;`BmWM~OCjSw<;N(}bm3G-Cq}N<@7PPe*_ITN3+Zs$o%)E(x0L)~Hn|`;4N~+C#l_#X> z+1KYYbeb9C8pGivhuM)RR~4_knEW3fo=HQF8>4~$o<%?`hA{h93Pd3)FiK<~0xn=S zU&>De+9citA^PxX%5ob4TSGB$V`tDpkzMHo{A9CUhpNprC^ZP@bakentWf1#8R=aS+~Ep(_^(c-MMk8^eHd- zIb70n9!d?3tP0sLXM5*SWQb6!cEBC+40B=|^Jpy?SJG7bq}~34Jl+5MnokajYBp@} zc~hcjss!_ON{Ihvu@gBGGu zaGgsifiIh!X4^BGh;b(?1VB|->j@iC(S^<|BQWZ6la!}|J?DDK&d~@-N{3c$aL9HO zd$Ut}6t{FROE5}|?nBK!^9C(wJ-@I#cgV%WvFv^f{2TnD)jm9%ga?j(<9-9N4L9Bm zE>$ny%v|+J;Hq4)MUI8T!AsDV=^VRYgH_v3PgZaXt=l5cf8Df#jD&s2hN5@#2d9DJ z3l?{f|9*P&lG8|@PtP*E_FYST(r9Z;u12Ir=qpE|q6sbv1H?zm|8l`)zZdn39@B<0 zt@|CqY&L?8dd~5gW|zlX%^^x6s$vjUzUR5N7_K5@$gcyPW-j0`{ek`u0u0+<>QTLw zo1WUc*eOaYN)$gedTspAh4Kpfnqt`*Q-=e`nN{uVbo#{zc88?n9)ESpbVXX#X(m+D z<^z{eV3!@STG%<*;yf1KvY2=)m>k&u-Nk&@qK6btr0ULlJLY^TaV96g7?^ErK_mJ@ zI*Lw~7(0}OPy*mp@mFC{coy98-e2&`zy}5+pPDcFH#`s{_jurqk>UYhkoBpd6Lwr} zWr%_(A)Y1M^~Rv$wd3*SXRYX84tB~=HVO(lRR~0p+)bwIYz70@k=%k1GXgt-Y{BI8 z6p;U{c%|ei(5L37+i!SCSvlInS349v!wppI#WB!|&Ts^k)4G8*j6tF{(=7ZEiLAtG?y^+Mz;WUyKcKI=A-#2XIZ^D1AtX%_- zh^Zmk8*Z~eu#5#Gh^S2lk}k_$WDWU=uu%h5cb-Jib7cxT%6K94d6M5f7V-= zeI$qvGX5V?>3k5j{jINo&TVe{Q(WM?n1(9#gW&t0Z~x*|-oDDji@@#X(pCC|Tav9^ zm|0b^_Mc_~r60d3GE^qz+8Vdq@-N26v4{>>e%D?kmSZoqb(}RG2#ji)=fD6@BS024 zuy0$OtIECoCHX#pLSti2vVZDtW5u7eJN*psjP9~T^{>BRyKme5-n0Gs#&oKK?XZv6 z#*2`LsG%6{V94i%koK+cemtF2PQtw-9&lHnix)@o(}-w^6F!UM``>tR7KT;8h;Txl zx`Hx;D|36!2tJV)@_8+Q3A(IlQ~T^o1~Vfw(?$d~ylcmc$Yt>mNMH;km*d zdk>;AuF*Cf2$b?L-hCmL!BusA3yw zkfeCD{K)Q0eSAC%&cT)Y-81YmmjqfcCcnS3ISDT<4jzkN2ngW;UaEblh3DN3n=(LiO41%p zT}tmRjTD5@jD=G%^m3ehg#g@s6#T?)aabFBwSjHAzB?o0L2T68FQC8t*kFV!)e?aH zXJ|cfvyLD|`GVLVJq32E#t*dpwou9kbG-`}U}%@(=O>!O)i{DlyD{VCdo|u6II+Jm zhIHD$VPC^`e$nvP@rrk73%HebzF55pa~8*)fN14mA>eZj8*X`93^j6$(+HqUJ|0m@ zs(BqKY6Rs6myymTaRX>XC;X(!OZbr2E&i{eHNOQON#~N+tkwiBD_g@Cnrn$-gUg6n zdh5RUlsjnX`Q}-QBU$C(AK%kI0a$`DOxlhwZl}$9Xyng|oP^z$#WP|XNibQP?tp%& z6?3`qpU4%?!Ih$36hqUB* zMmrIqD5BT3K?(67QrR9-fBj1k91s7_F` zR>RM;+AP0pk7j@Vpv(i&gPpq7kRZ=>%0qWdxt=^V55G%_|*poX;43WKap3L2?P) zS|J}GdMLxN60K%cR9DItEko^IYojZ;Jc7StQ>p5)VI*N56yGEweVM8JnEAd6&zD!h zas-LH;6fa77rmu*XUDuc>`2@9_JAJc^B|KtQSvie z#Nmq9(6jt-tyY_-G=Qj4*Jr>zf&}1SAlC}f)yr&5rJGz=BnQ9r(*#Fd#t&Q_3-)=w zSYV+ddIR>c7Ygu9nxe=o916}9!?41(lr*^3O$vNa4*?t99?Qw~9#l2^*_9Q8U ziypNYmTbPn1_^PGxGVmndT0KL97pm#0yd~sjCLf~e7_F&lYDB*N~=!XH$a&ggKb5W zu~)uqSySxT_e9*SHy*04C}U^1vBDRvsM{z=gzd3K`-MV|_yUWCd0sWZPkO}=t3;WD zB!n;?R$}Loo_4%su8Q6v#wWqIEf*MKoW#-(Qa!7ErKfyUH*Xk=vtN!MB%cP%Z zy}RXF;PXHnogA+X)-5DRly8)wMG2+x(q@mXB1&eU$l1n*KsoGw>O+gjO z&Q~zXMf@ZQ1QvRu;ct%Ga#cKXNeU;>e9HycH>h%Q#H)JwBHV4`S1LC z3Fp~H7wZ?z;H{fhFj(b{TU1p7qT$UWBV+|H!j!0n6jBYLb&x?{(6|CX*>X`st5~Leo8L8K5vm z&WbmSl(oZ37i&V*AV&zT;UzB&L>EAeXMEgLbiKSv4TS}iIo%HMf1JXXG*gGxW%x<~ ztFJdpkt^3zHS;qx)$ra~8T97KI3pL4~E`^u~3^_Jr98qo9O zYE)x4p;{gHG(l^&Ir6aW@jCFr^^5xG8MNaNgBeJka8ER~Go8*F;`|;;2_m#Avl60? zou8@u`R7)AIUyMn;$6db8Hj&;+m+h{WtfSwe#pbGe0YU@%{Be6yNs?>RX2k{X^va% z$pS4Gi@RJqjDER>~utNZ=Vx5jO{PSFkqK{gOqWu@={)frI#ozd@@bjdM z9{g{>*#D-i{cqIV|K{!CeJlT4g#RBd!Y1VMZgO&=v-vk;K7Ifu;E|SrW~I6f^8W#* CsPjPp literal 0 HcmV?d00001 From 07903cd61544d58b26708d3fb821b83101a8d744 Mon Sep 17 00:00:00 2001 From: Proxiweb <208025616+ProxiwebLabs@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:43:14 +0100 Subject: [PATCH 3/4] fix: declare Tauri context JSON dependency --- tauri/src-tauri/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index d21ceca..f2f2c08 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2.5.1", features = [] } [dependencies] +serde_json = "1" tauri = { version = "2.9.5", features = [] } tauri-plugin-deep-link = "2.4.10" tauri-plugin-os = "2.3.2" From 263bfc9c224f2ba420fa6c0c30426b491946e8a7 Mon Sep 17 00:00:00 2001 From: Proxiweb <208025616+ProxiwebLabs@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:04:19 +0100 Subject: [PATCH 4/4] fix: enforce Tauri mobile trust boundaries --- scripts/validate.ps1 | 2 + scripts/validate.sh | 2 + tauri/README.md | 10 +++-- .../ios/Sources/ZqDeferredPlugin.swift | 3 ++ .../PluginTests/HandoffParserTests.swift | 10 ++--- tauri/src/config.ts | 6 ++- tauri/src/controller.test.ts | 6 +++ tauri/src/main.ts | 18 +++++++-- tauri/src/navigation-queue.test.ts | 40 +++++++++++++++++++ tauri/src/navigation-queue.ts | 9 +++++ 10 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 tauri/src/navigation-queue.test.ts create mode 100644 tauri/src/navigation-queue.ts diff --git a/scripts/validate.ps1 b/scripts/validate.ps1 index 99b7bd8..002bc6e 100644 --- a/scripts/validate.ps1 +++ b/scripts/validate.ps1 @@ -66,6 +66,7 @@ if ($Platform -in @('all','tauri')) { $ios = Get-Content "$root/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift" -Raw $android = Get-Content "$root/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/ZqDeferredPlugin.kt" -Raw $config = Get-Content "$root/tauri/src/config.ts" -Raw + $tauriConfig = Get-Content "$root/tauri/src-tauri/tauri.conf.json" -Raw | ConvertFrom-Json $singleIndex = $rust.IndexOf('tauri_plugin_single_instance::init') $deepIndex = $rust.IndexOf('tauri_plugin_deep_link::init') @@ -74,6 +75,7 @@ if ($Platform -in @('all','tauri')) { if ($ios -match 'UIPasteControl' -and $ios -notmatch 'UIPasteboard\.general') { Ok 'tauri_ios_explicit_paste_control' } else { Fail 'tauri_ios_paste_boundary_invalid' } if ($android -match 'InstallReferrerClient' -and $android -match 'AtomicBoolean' -and $android -notmatch 'SharedPreferences|android\.util\.Log') { Ok 'tauri_android_one_shot_referrer' } else { Fail 'tauri_android_referrer_boundary_invalid' } if ($controller -match "platform === 'Desktop'" -and $controller -match 'acknowledgeAfterRender') { Ok 'tauri_desktop_ack_out_of_scope' } else { Fail 'tauri_desktop_ack_boundary_missing' } + if ($config -match [regex]::Escape("iOS: '$($tauriConfig.identifier)'") -and $config -match [regex]::Escape("Android: '$($tauriConfig.identifier)'")) { Ok 'tauri_mobile_identity_matches_bundle' } else { Fail 'tauri_mobile_identity_mismatch' } if ($config -match 'links\.example\.com' -and $config -match 'com\.example\.zipquantum') { Warn 'tauri_uses_example_identifiers' } else { Ok 'tauri_identifiers_configured' } } diff --git a/scripts/validate.sh b/scripts/validate.sh index 54c25cb..40182e3 100644 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -47,6 +47,7 @@ if [ "$PLATFORM" = all ] || [ "$PLATFORM" = tauri ]; then IOS="$ROOT/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift" ANDROID="$ROOT/tauri/plugins/deferred-link/android/src/main/java/com/zipquantum/tauri/deferredlink/ZqDeferredPlugin.kt" CONFIG="$ROOT/tauri/src/config.ts" + TAURI_IDENTIFIER=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["identifier"])' "$ROOT/tauri/src-tauri/tauri.conf.json") SINGLE_LINE=$(grep -n 'tauri_plugin_single_instance::init' "$RUST" | head -n 1 | cut -d: -f1 || true) DEEP_LINE=$(grep -n 'tauri_plugin_deep_link::init' "$RUST" | head -n 1 | cut -d: -f1 || true) @@ -55,6 +56,7 @@ if [ "$PLATFORM" = all ] || [ "$PLATFORM" = tauri ]; then grep -q UIPasteControl "$IOS" && ! grep -q 'UIPasteboard\.general' "$IOS" && ok tauri_ios_explicit_paste_control || fail tauri_ios_paste_boundary_invalid grep -q InstallReferrerClient "$ANDROID" && grep -q AtomicBoolean "$ANDROID" && ! grep -q -E 'SharedPreferences|android\.util\.Log' "$ANDROID" && ok tauri_android_one_shot_referrer || fail tauri_android_referrer_boundary_invalid grep -q "platform === 'Desktop'" "$CONTROLLER" && grep -q acknowledgeAfterRender "$CONTROLLER" && ok tauri_desktop_ack_out_of_scope || fail tauri_desktop_ack_boundary_missing + grep -q "iOS: '$TAURI_IDENTIFIER'" "$CONFIG" && grep -q "Android: '$TAURI_IDENTIFIER'" "$CONFIG" && ok tauri_mobile_identity_matches_bundle || fail tauri_mobile_identity_mismatch grep -q links.example.com "$CONFIG" && grep -q com.example.zipquantum "$CONFIG" && warn tauri_uses_example_identifiers || ok tauri_identifiers_configured fi diff --git a/tauri/README.md b/tauri/README.md index ed68885..223787c 100644 --- a/tauri/README.md +++ b/tauri/README.md @@ -24,8 +24,9 @@ ZipQuantum dashboard: - `links.example.com` in `src/config.ts` and `src-tauri/tauri.conf.json`; - `app.example.com` and the allowlisted application routes in `src/routing.ts`; -- `com.example.zipquantum` app identifiers; -- `com.example.zipquantum.tauri` Tauri bundle identifier. +- `com.example.zipquantum.tauri` in both `src/config.ts` and the Tauri bundle + identifier. They must remain identical because `mobile-v1` binds recovery and + acknowledgements to the installed app identity. Do not commit a live token, signing certificate, provisioning profile, handoff URL, route receipt, or user data. @@ -81,5 +82,6 @@ powershell -File scripts/validate.ps1 tauri ``` The checks use stable `ZQ_OK`, `ZQ_WARN`, and `ZQ_ERROR` prefixes. The TypeScript -tests cover cold/warm direct delivery, deferred delivery, unknown routes, -one-shot acknowledgement, and the desktop no-ack boundary. +tests cover cold/warm direct delivery, serialized warm delivery, deferred +delivery, unknown routes, one-shot acknowledgement, and the desktop no-ack +boundary. diff --git a/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift b/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift index f073560..cf80ead 100644 --- a/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift +++ b/tauri/plugins/deferred-link/ios/Sources/ZqDeferredPlugin.swift @@ -115,6 +115,9 @@ final class ZqDeferredPlugin: Plugin { invoke.resolve(["handoff": ["token": handoff.token, "host": handoff.host]]) } controller.modalPresentationStyle = .formSheet + // A swipe-to-dismiss does not invoke the explicit Cancel handler and + // would otherwise leave the Tauri command unresolved forever. + controller.isModalInPresentation = true self.presentViewController(controller) } } diff --git a/tauri/plugins/deferred-link/ios/Tests/PluginTests/HandoffParserTests.swift b/tauri/plugins/deferred-link/ios/Tests/PluginTests/HandoffParserTests.swift index ef8b975..28d0b69 100644 --- a/tauri/plugins/deferred-link/ios/Tests/PluginTests/HandoffParserTests.swift +++ b/tauri/plugins/deferred-link/ios/Tests/PluginTests/HandoffParserTests.swift @@ -5,8 +5,8 @@ final class HandoffParserTests: XCTestCase { func testExpectedHandoff() { XCTAssertEqual( HandoffParser.parse( - "zqddl://recover?token=opaque_token&host=links.example.com&bundle_id=com.example.zipquantum", - expectedBundleID: "com.example.zipquantum" + "zqddl://recover?token=opaque_token&host=links.example.com&bundle_id=com.example.zipquantum.tauri", + expectedBundleID: "com.example.zipquantum.tauri" ), DeferredHandoff(token: "opaque_token", host: "links.example.com") ) @@ -15,11 +15,11 @@ final class HandoffParserTests: XCTestCase { func testRejectsMismatchAndDuplicates() { XCTAssertNil(HandoffParser.parse( "zqddl://recover?token=opaque_token&host=links.example.com&bundle_id=other.example", - expectedBundleID: "com.example.zipquantum" + expectedBundleID: "com.example.zipquantum.tauri" )) XCTAssertNil(HandoffParser.parse( - "zqddl://recover?token=one&token=two&host=links.example.com&bundle_id=com.example.zipquantum", - expectedBundleID: "com.example.zipquantum" + "zqddl://recover?token=one&token=two&host=links.example.com&bundle_id=com.example.zipquantum.tauri", + expectedBundleID: "com.example.zipquantum.tauri" )) } } diff --git a/tauri/src/config.ts b/tauri/src/config.ts index 09c2cc9..5473f52 100644 --- a/tauri/src/config.ts +++ b/tauri/src/config.ts @@ -3,8 +3,10 @@ export const ZQ_CONFIG = Object.freeze({ allowedLinkHosts: Object.freeze(['links.example.com']), allowedDestinationHost: 'app.example.com', appIdentifiers: Object.freeze({ - iOS: 'com.example.zipquantum', - Android: 'com.example.zipquantum', + // These must match `src-tauri/tauri.conf.json > identifier`: the server + // binds mobile-v1 recovery and acknowledgements to the installed app. + iOS: 'com.example.zipquantum.tauri', + Android: 'com.example.zipquantum.tauri', }), consentVersion: 'example-1', desktopScheme: 'zq-example:', diff --git a/tauri/src/controller.test.ts b/tauri/src/controller.test.ts index fdec725..af054a0 100644 --- a/tauri/src/controller.test.ts +++ b/tauri/src/controller.test.ts @@ -36,6 +36,9 @@ describe('direct and deferred delivery', () => { 'https://a.zq.tn/api/mobile/v1/links/resolve', 'https://a.zq.tn/api/mobile/v1/links/resolve', ]); + expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toMatchObject({ + package_name: 'com.example.zipquantum.tauri', + }); }); it('acknowledges once and only after the caller renders', async () => { @@ -62,6 +65,9 @@ describe('direct and deferred delivery', () => { expect(navigation.route).toEqual({kind: 'product', identifier: 'example-42'}); expect(String(fetcher.mock.calls[0]?.[0])).toBe('https://a.zq.tn/api/mobile/v1/deferred/recover'); + expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toMatchObject({ + package_name: 'com.example.zipquantum.tauri', + }); }); it('never emits a desktop route acknowledgement', async () => { diff --git a/tauri/src/main.ts b/tauri/src/main.ts index d89db5f..0f0e2f5 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -3,6 +3,7 @@ import {type} from '@tauri-apps/plugin-os'; import {DeliveryController} from './controller'; import './style.css'; import type {RuntimePlatform} from './config'; +import {NavigationQueue} from './navigation-queue'; import type {PendingNavigation} from './types'; import {getPendingRoute} from './zq-deferred'; @@ -19,6 +20,7 @@ function runtimePlatform(): RuntimePlatform { const platform = runtimePlatform(); const controller = new DeliveryController(platform); +const navigationQueue = new NavigationQueue(); function renderStatus(message: string, isError = false): void { root.innerHTML = ` @@ -60,7 +62,13 @@ async function handleDirectUrls(values: string[]): Promise { renderStatus('The link did not match an allowlisted route.', true); } -async function recoverDeferred(): Promise { +function enqueueDirectUrls(values: string[]): Promise { + // Keep the complete render/ack sequence serial: simultaneous warm-link + // events must not acknowledge a route replaced before its first paint. + return navigationQueue.enqueue(() => handleDirectUrls(values)); +} + +async function recoverDeferredNow(): Promise { try { const handoff = await getPendingRoute(); if (!handoff) { @@ -73,13 +81,17 @@ async function recoverDeferred(): Promise { } } +function recoverDeferred(): Promise { + return navigationQueue.enqueue(recoverDeferredNow); +} + async function start(): Promise { renderStatus('Waiting for a direct or deferred deep link.'); await onOpenUrl((urls) => { - void handleDirectUrls(urls); + void enqueueDirectUrls(urls); }); const coldStartURLs = await getCurrent(); - if (coldStartURLs?.length) await handleDirectUrls(coldStartURLs); + if (coldStartURLs?.length) await enqueueDirectUrls(coldStartURLs); } void start(); diff --git a/tauri/src/navigation-queue.test.ts b/tauri/src/navigation-queue.test.ts new file mode 100644 index 0000000..7dae14d --- /dev/null +++ b/tauri/src/navigation-queue.test.ts @@ -0,0 +1,40 @@ +import {describe, expect, it, vi} from 'vitest'; +import {NavigationQueue} from './navigation-queue'; + +describe('navigation queue', () => { + it('does not overlap route rendering and acknowledgement', async () => { + const queue = new NavigationQueue(); + const order: string[] = []; + let releaseFirst: (() => void) | undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = queue.enqueue(async () => { + order.push('first:start'); + await firstGate; + order.push('first:ack'); + }); + const secondTask = vi.fn(async () => { + order.push('second:start'); + }); + const second = queue.enqueue(secondTask); + + await Promise.resolve(); + expect(secondTask).not.toHaveBeenCalled(); + releaseFirst?.(); + await Promise.all([first, second]); + expect(order).toEqual(['first:start', 'first:ack', 'second:start']); + }); + + it('continues after a failed delivery', async () => { + const queue = new NavigationQueue(); + const first = queue.enqueue(async () => { + throw new Error('rejected route'); + }); + const second = queue.enqueue(async () => undefined); + + await expect(first).rejects.toThrow(/rejected route/); + await expect(second).resolves.toBeUndefined(); + }); +}); diff --git a/tauri/src/navigation-queue.ts b/tauri/src/navigation-queue.ts new file mode 100644 index 0000000..67e1d0b --- /dev/null +++ b/tauri/src/navigation-queue.ts @@ -0,0 +1,9 @@ +export class NavigationQueue { + private tail: Promise = Promise.resolve(); + + enqueue(task: () => Promise): Promise { + const next = this.tail.then(task, task); + this.tail = next.catch(() => undefined); + return next; + } +}