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 @@ + + +
+ + + +${message}
+ ${platform === 'Desktop' ? '' : ''} +No fingerprinting. No advertising identifier. Receipts stay opaque and in memory.
+The route acknowledgement is sent only after this view renders.
+uhPl{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={)|B 8OO_f?(M5?Q#T;7=4o({IhJ=hf+azA$RJDU u414@*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?V DzOra+Ziub pX+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@gl hE|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@R7g9 Fy6Avv6^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^ z5wa Ng8bBf(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|0W 3NdxxUL5gA8gg$@l~ zFdct3+IxCNI}K)`I@x!vN#g2%JwN;;N^Y=nbz4G{0u^p(I(U2z0ZM$E$Nf MH 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&D cpNN-sb$E~6Ith4Fis}U2SuD+|t%;a@0GbDO3xDy5 z5-+4s7j&ZW_{GiC;)MS p9$#t0c451)c6WO5P1zfx9Fb#)+pOn7_s8Cj8E4_A8ex=oP6y3HmKc06hGXu=}Hx z9a*gUd0qIeKkpV^R?EhM=nyw*twufsp9183yi+%;1sW_R&CA%)v4eT BWB` 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#*KLNVe^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}PD5GTbHGH hIL1Vd<#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+fA3I f(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#}N4 a@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$CGk6 ZIQhr?y#SR0>T?T@l8)?>8sE|; _?*w{=2ZG!3U?TEZ mZ1A}3~Y>7gi~N0N+tANB3MtHNzZufuEd8 w?L(4tz2z~QRqR)Tv>-(so^3ciHw=cliD&i Q(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`Uog4 vI1qeXZ(aW!@$?IFL43x zlSsKyf{K!gdgFAKVd*ozmxkQnXlY)3-OOSRS5+q*ANB|}vJ4Id>{#j1Y@NN^rl77M zu)$QTB^cQJB3FJf v5 Uij5AlaI47& z&9Pbnx>sD%=?}N;LsQ3dT@6R`OJT!(E~p$={lI#$Z+NaMr2Q`mpIXp40>+lxkME9x z`bEiFNUXy#k0SgDs j$2V0wQ&p#lZ@lp`PQ!H%k69aHSdNRC7c50%toPoIS_ zcX}~CQSSB5hhg<7T79M2uw(pEnvl<%7UX V2)*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&URW ILi4jO*%Qc|dia=}aP8oik9FW3$Ji>_3sk@= zye~dXzehDdKh2ec=&xw^@djdq<*xH}I{L#CN2g6ngO*e=?{D O&^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@KQzI 9YPfebg}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?{A CYcWRK 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$ zW dS8GZwlqzU`|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 z G*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%M nGMT{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#frn7E 7zbxc3N94=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_r}Bu;=4}R( zQ0k|VjA`V$^K9Eq^3XV>qAhsZz+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;XHNoN4wP m2Um@{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^b85 h^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+ Ot EZ*#8E=W%`hYRJn z`dP ELesXnQzIAk^$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>vhJkg b7RPy2{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{^k lNS|fA zn!i;#4Q_Rg@Vvr^jge`8cE?d24`CjCd-m(jZ?F519W5 FQ@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@2 TxDOmCgMcz$_7wihQ`;K-uU&6 _5VosSWAdE!qtFSC zDy3$Aeh?uN_hs}CF+RWAe7yem_&MGmU9*)@mv&m0Y&ps(@Z|C3*78u LJH8_j0Y%r{kcdc9vLq@i8g1+7NLM>w*lTc zcuj1f$C!$CO4$3
PSM|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$C1CosgBIlIGw>1tS*GM>>m6Y4= z2I;lvs;`6Ya;ia;@FR4?^4SEODMtjwr&!pom+gQf 54aqd*_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{A9CUhp NprC^ZP@bakentWf1#8R=aS+~Ep(_^(c-MMk8^eHd- zIb70n9!d?3tP0sLXM5*SWQb6!cEBC+40B=|^Jpy?SJG7bq}~34Jl+5MnokajYBp@} zc~hcjss!_ON{Ihvu@gBGG u 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*j6t F{(=7ZEiLAtG?y^+Mz;WUyKcK I=A-#2XIZ^D1AtX%_- zh^Zmk8*Z~eu#5#Gh^S2lk}k_$WDWU=uu%h5cb-Jib7cxT%6K94 d6M5f7V-= 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-81Ymmjq fcCcnS3ISDT<4jzkN2ng W;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$36hq UB* 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>c 7Ygu9nxe=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%su8 Q6v#wWqIEf*MKoW#-(Qa!7ErKfyUH*Xk=vtN!MB%cP%Z zy}RXF;PXHnogA+X)-5DRly8)wMG2+x(q@mXB1&eU$l1n*KsoGw>O+gj O z&Q~zX Mf@ZQ1QvRu;ct%Ga#cKXNeU;>e9HycH>h%Q#H)JwBHV4`S1LC z3Fp~H7wZ?z;H{fhFj(b{TU1p7qT$UWBV+|H!j!0n6jBYLb&x?{(6|C
X*>X`st5~Leo8L8K5vm z&WbmSl(oZ37i&V*AV&zT;UzB&L>EAeXMEgLbiKSv4TS}iIo%HMf1JXXG*gGxW%x<~ ztFJdpkt^3z HS;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; + } +}