Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ DerivedData/
xcuserdata/
local.properties
**/node_modules/
**/dist/
**/target/
**/.dart_tool/
.flutter-plugins*
.playwright-cli/
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions ai-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
7 changes: 7 additions & 0 deletions docs/agent-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions docs/mobile-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions scripts/validate.ps1
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -58,5 +59,25 @@ 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
$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')

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 [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' }
}

if ($errors -gt 0) { exit 1 }
Ok 'static_validation_complete'
22 changes: 21 additions & 1 deletion scripts/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,5 +40,25 @@ 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"
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)
[ -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 "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

[ "$ERRORS" -eq 0 ] || exit 1
ok static_validation_complete
87 changes: 87 additions & 0 deletions tauri/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 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.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.

## 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, serialized warm delivery, deferred
delivery, unknown routes, one-shot acknowledgement, and the desktop no-ack
boundary.
13 changes: 13 additions & 0 deletions tauri/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<title>ZipQuantum Tauri example</title>
</head>
<body>
<main id="app" aria-live="polite"></main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Loading
Loading