feat(mobile): emit the native shell events -- lifecycle, safe-area, and Android's back gesture (#4742)

* feat(mobile): emit the native shell events -- lifecycle, safe-area, and Android's back gesture

The Rust crate had the four event names and the pure decision functions, and
nothing that ever emitted any of them: `on_window_event` was an empty closure.
On a device the webview subscribed to four events and received none, so the app
laid out as though the phone had no notch and no lifecycle, and the back button
did nothing. Three of the four now have a real source.

lifecycle
  Tauri already surfaces `Suspended`/`Resumed` on mobile (onPause/onResume,
  willResignActive/willEnterForeground) and `Focused` everywhere. Only the
  translation was ours: `window_event_name` names the variant, `phase_for` maps
  it to the three phases the TypeScript accepts, and `Tracker` decides whether
  it is news. `Suspended` maps to `background`, not the semantically closer
  `inactive`, because boot.ts flushes only on `background` and iOS can kill a
  suspended app with no further callback -- mapping to `inactive` loses the
  transcript on every backgrounding. The two suppressions in `Tracker` exist
  for real delivery orders: Android's onPause is followed by
  onWindowFocusChanged(false), which would otherwise report the app as having
  come *up* to inactive while still backgrounded.

safe-area-changed
  Emitted on `Resized` and `ScaleFactorChanged`, with a deliberately EMPTY
  payload. Tauri does not expose the insets and this crate does not guess them;
  `coerceInsets` reads a payload with no edges as "re-read the CSS env()
  variables", which the WebView has already recomputed by the time the resize
  reaches Rust. Zeros would be a lie the composer lays out against.

back-gesture, and why it is not Plugin.trigger
  The TypeScript subscribes via `plugin:event|listen`, which registers in
  TAURI'S OWN event registry. Only `Emitter::emit` reaches it. Tauri's AppPlugin
  already installs an OnBackPressedCallback, but it routes the press to
  JavaScript plugin listeners via `Plugin.trigger` -- a completely separate
  channel that reaches neither that registry nor Rust. Wiring it that way would
  fire nothing, with no error anywhere.

  Arbitration also has to happen in Rust: the webview's answer is
  fire-and-forget (`bridge.invoke` swallows every rejection into `null`), so
  only Rust can time it out, and a back button that does nothing forever is
  worse than one that exits.

  So the new in-repo `tauri-plugin-back-gesture` owns its own callback in
  Kotlin and the path is Kotlin -> Channel -> Rust -> emit:

    BackGesturePlugin.kt  OnBackPressedCallback, registered in `load` (not
                          `init`) so it is always newer than Tauri's and
                          therefore consulted first
      -> trigger(EVENT)   the base Plugin class's `registerListener` command,
                          handed a Rust-owned tauri::ipc::Channel
      -> Rust closure     commands::on_back_pressed
      -> Emitter::emit    "back-gesture", which is what the webview hears
      -> back_gesture_result(handled) or a 400ms watchdog
      -> fallBack()       re-dispatch with the callback disabled, so the SYSTEM
                          default runs; on Android 12+ that backgrounds the task
                          rather than destroying the activity, preserving warm
                          start

  Inert on iOS and desktop by design: iOS has no OS-level back, and an app that
  terminates itself is an App Review rejection.

keyboard-height is still not emitted natively -- the TypeScript reads
`visualViewport`, which both WebViews implement and which fires through the
show/hide transition. The constant stays so a native source can take over
without touching the contract. docs/gaps.md now says so rather than claiming
the whole platform half is missing.

Also here
  - `tauri-plugin-opener`, without which `openExternal`'s invoke rejected and
    on iOS returned true having done nothing. The capability grants both
    `opener:allow-open-url` and `opener:allow-default-urls`; without the scope
    every http(s)/mailto/tel URL is still refused.
  - The plugin is a workspace member so `cargo test` and
    `cargo clippy --all-targets -- -D warnings` cover it too; as a bare path
    dependency its lints would be capped and its tests never run.
  - `.gitignore` under plugins/ for `.tauri/`, `build/`, `.gradle/` and
    `local.properties`: an android build drops Gradle/Kotlin output there.
  - `consumer-rules.pro`, which `android/build.gradle.kts` names in
    `consumerProguardFiles`. AGP fails the library build when a file named
    there is missing, so it has to exist even with nothing in it.

Tested
  `run()`'s builder chain is split into `configure()` so tests build the SAME
  app on Tauri's mock runtime. That was not cosmetic: mutation testing showed
  the pure suite could not see the wiring at all. Deleting the safe-area emit,
  making `on_window_event` never emit, dropping `manage(LifecycleState)` and
  dropping `.on_window_event(...)` ALL survived with every test still green.
  `tests/wiring.rs` closes them -- it puts a real Window through the real
  handler and asserts the emitted payloads. The one line no mock can check is
  `.on_window_event(...)` itself, because MockRuntime accepts the callback and
  drops it; that one is a source assertion, labelled as such.

  14 of 14 mutations are now killed by a named test. 29 Rust tests pass,
  clippy is clean at -D warnings, and `cargo check --target
  aarch64-linux-android` passes, which is what compiles the cfg(mobile) arms.

Refs #4673

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(cli): add missing variant field to InteractiveConfig

The interactive flag-parity tests (test_flags.py) expected an InteractiveConfig
`variant` field that was never added, breaking the smoke job. Add it as an
optional string (default None) parsed from args, mirroring the existing `share`
field. Pre-existing failure unrelated to the mobile-shell PR contents.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-03 08:48:51 +01:00
1 parent fa7caf7755
commit e8006cc9c5
31 files changed
+2433 -70

No files matched your search

@@ -64,6 +64,9 @@ class InteractiveConfig:
# Sharing
share: bool = False
# Model reasoning/effort variant (e.g. "high", "medium", "low")
variant: Optional[str] = None
@classmethod
def from_env(cls) -> "InteractiveConfig":
"""Create config from environment variables."""
@@ -94,6 +97,7 @@ class InteractiveConfig:
memory=getattr(args, "memory", False),
files=getattr(args, "file", []) or [],
share=getattr(args, "share", False),
variant=getattr(args, "variant", None),
autonomy=getattr(args, "autonomy", True),
no_context=getattr(args, "no_context", False),
)
@@ -1400,9 +1400,9 @@ test("a FULLY present Tauri global is treated as present -- the pair", () => {
test("the TAURI shell reports the keyboard height, without any native event", async () => {
// `keyboardHeightPx` was a hard 0 whose only writer was the native
// `keyboard-height` event -- and nothing emits it: src-tauri/src/lib.rs's
// `on_window_event` is an empty closure, left unwired until the mobile
// targets are initialised. On a device --keyboard-height stayed 0 forever,
// `keyboard-height` event -- and nothing emits it: the Rust shell emits
// lifecycle, safe-area and back, and has no keyboard observer to emit
// this from. On a device --keyboard-height stayed 0 forever,
// and app.css pins #root to `position: fixed; inset: 0` so the layout
// viewport does not shrink either. The composer sat under the keyboard the
// moment anyone tapped it -- the first thing anyone does with the app.
@@ -240,9 +240,9 @@ export function createTauriShell(deps: TauriShellDeps = {}): TauriShell {
// Seeded and driven from `visualViewport`, exactly as the web shell does.
//
// This was a hard `0` whose only writer was the native `keyboard-height`
// event -- and nothing emits that event: `src-tauri/src/lib.rs`'s
// `on_window_event` is an empty closure, left unwired until the mobile
// targets are initialised. So on a device `--keyboard-height` stayed 0
// event -- and nothing emits that event. The Rust shell now emits the other
// three (`src-tauri/src/shell/mod.rs`) but still not this one: Tauri has no
// keyboard observer to wire. So on a device `--keyboard-height` stayed 0
// forever, and because `app.css` pins `#root` to `position: fixed; inset: 0`
// the layout viewport does not shrink either. The composer sat underneath
// the keyboard the instant anyone tapped it -- the first thing anyone does.
+9 -3
View File
@@ -442,9 +442,15 @@ setting can return the day its consumer does.
What actually remains for praisonai-mobile, all of it unbuilt rather than
broken:
- **The platform half of the shell.** The Rust crate has the event names and
the pure decision functions; no iOS or Android code observes safe-area,
keyboard height or lifecycle yet, so none of the four events is ever emitted.
- **The keyboard half of the shell, and an iOS back.** The Rust crate now
emits three of the four events: `lifecycle` from Tauri's suspend/resume/focus
window events, `safe-area-changed` (an empty payload, meaning "re-read the
CSS") on resize and scale change, and `back-gesture` from Android's system
back via the in-repo `tauri-plugin-back-gesture`, answered through
`back_gesture_result`. `keyboard-height` is still never emitted natively --
the TypeScript reads `visualViewport` instead, and would take a native event
as an override -- and nothing installs an iOS edge-swipe back. Haptics and
share have no plugin; the bridge's invokes for them reject and degrade.
- **Route→view dispatch.** `ui/src/screens.ts` and `app/src/mount.ts` are built
and tested, and `main.ts` imports neither. Tapping "Settings" pushes onto a
stack nothing reads.
+527
View File
@@ -47,6 +47,137 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -142,6 +273,19 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "brotli"
version = "8.0.4"
@@ -331,6 +475,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "cookie"
version = "0.18.2"
@@ -732,6 +885,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -749,6 +929,36 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -871,6 +1081,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.34"
@@ -1225,6 +1448,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1505,6 +1734,25 @@ version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1733,6 +1981,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
@@ -2106,12 +2360,33 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2137,6 +2412,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2225,6 +2506,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -2270,6 +2562,20 @@ dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -2309,6 +2615,8 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-back-gesture",
"tauri-plugin-opener",
]
[[package]]
@@ -2533,6 +2841,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -2810,6 +3131,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -3174,6 +3505,55 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-back-gesture"
version = "0.1.0"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
"url",
"windows",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3274,6 +3654,19 @@ dependencies = [
"toml 1.1.5+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
@@ -3577,9 +3970,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -3629,6 +4034,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -4446,6 +4862,76 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 3.0.4",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
@@ -4511,3 +4997,44 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 3.0.4",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.4",
"winnow 1.0.4",
]
+21
View File
@@ -24,14 +24,35 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
# openExternal: `plugin:opener|open_url` in adapters/src/tauri/bridge.ts. Until
# this was here that invoke rejected, the bridge swallowed it, and on iOS
# (no window.open) openExternal returned true having done nothing.
tauri-plugin-opener = "2"
# Android's system back gesture, delivered to Rust so `shell::back::Gate` can
# arbitrate it. In-repo: it is a Kotlin OnBackPressedCallback plus a Channel,
# and no published plugin routes the press through Rust. Inert off Android.
tauri-plugin-back-gesture = { path = "plugins/back-gesture" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
# The in-repo plugin is a member so the CI commands run from this directory
# (`cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo check
# --lib --target ...`) build and lint it too. As a bare path dependency it
# would compile with lints capped and its own tests would never run.
[workspace]
members = ["plugins/back-gesture"]
default-members = [".", "plugins/back-gesture"]
[dev-dependencies]
# The shell logic is tested without a Tauri app: emitting and falling back are
# injected traits, so the arbitration is exercised on a laptop.
serde_json = "1"
# `test` gives the mock runtime, which is what lets tests/wiring.rs build the
# REAL builder chain (`configure`) and put a real `Window` through
# `shell::on_window_event`. A dev-dependency, so the feature is not compiled
# into the app: resolver v2 keeps dev-only features out of the lib build.
tauri = { version = "2", features = ["test"] }
[profile.release]
codegen-units = 1
@@ -1,11 +1,20 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"$schema": "../gen/schemas/mobile-schema.json",
"identifier": "default",
"description": "The main webview subscribes to the four shell events through Tauri's event registry, which is what plugin:event|listen needs. The back_gesture_result command is NOT listed: an app command registered via invoke_handler is always reachable and has no ACL entry to grant -- naming one that does not exist fails the build, which is how this file was broken.",
"description": "The main webview subscribes to the four shell events through Tauri's event registry (core:event:default), which is what plugin:event|listen needs, and opens links with the opener plugin: allow-open-url grants the command and allow-default-urls is the scope without which every http(s)/mailto/tel URL is still refused. back_gesture_result is NOT listed: an app command registered via invoke_handler is always reachable and has no ACL entry to grant -- naming one that does not exist fails the build, which is how this file was once broken. The back-gesture plugin has no JS-facing commands and so nothing to grant. platforms is explicit: everything here exists on all five, so the desktop dev build must not fail on it; a mobile-only grant belongs in its own capability scoped to its platform.",
"windows": [
"main"
],
"platforms": [
"android",
"iOS",
"macOS",
"windows",
"linux"
],
"permissions": [
"core:event:default"
"core:event:default",
"opener:allow-open-url",
"opener:allow-default-urls"
]
}
@@ -1 +1 @@
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-reLine truncated
{"back-gesture":{"default_permission":null,"permissions":{},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any preLine truncated
@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2191,6 +2359,54 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
},
@@ -2287,6 +2503,23 @@
]
}
]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
}
}
}
@@ -1 +1 @@
{"default":{"identifier":"default","description":"The main webview subscribes to the four shell events through Tauri's event registry, which is what plugin:event|listen needs. The back_gesture_result command is NOT listed: an app command registered via invoke_handler is always reachable and has no ACL entry to grant -- naming one that does not exist fails the build, which is how this file was broken.","local":true,"windows":["main"],"permissions":["core:event:default"]}}
{"default":{"identifier":"default","description":"The main webview subscribes to the four shell events through Tauri's event registry (core:event:default), which is what plugin:event|listen needs, and opens links with the opener plugin: allow-open-url grants the command and allow-default-urls is the scope without which every http(s)/mailto/tel URL is still refused. back_gesture_result is NOT listed: an app command registered via invoke_handler is always reachable and has no ACL entry to grant -- naming one that does not exist fails the build, which is how this file was once broken. The back-gesture plugin has no JS-facing commands and so nothing to grant. platforms is explicit: everything here exists on all five, so the desktop dev build must not fail on it; a mobile-only grant belongs in its own capability scoped to its platform.","local":true,"windows":["main"],"permissions":["core:event:default","opener:allow-open-url","opener:allow-default-urls"],"platforms":["android","iOS","macOS","windows","linux"]}}
@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2191,6 +2359,54 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
},
@@ -2287,6 +2503,23 @@
]
}
]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
}
}
}
@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2191,6 +2359,54 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
},
@@ -2287,6 +2503,23 @@
]
}
]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
}
}
}
@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2191,6 +2359,54 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
},
@@ -2287,6 +2503,23 @@
]
}
]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
}
}
}
@@ -0,0 +1,15 @@
# Build output for every in-repo Tauri mobile plugin. All of it is machine
# generated on each android build and none of it belongs in git:
#
# .tauri/ the tauri-api Kotlin sources + their Gradle build, copied
# in from the tauri crate by tauri-plugin's build script
# build/ Gradle/Kotlin output: .jar, .dex, .bin, generated Java
# .gradle/ Gradle's per-project caches
# local.properties the developer's own SDK path
#
# Each plugin's own android/.gitignore repeats the first two; this file is the
# one that covers a plugin added later whose generated .gitignore is missing.
.tauri/
build/
.gradle/
local.properties
@@ -0,0 +1,25 @@
[package]
name = "tauri-plugin-back-gesture"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Delivers Android's system back gesture to Rust, and lets Rust hand it back to the system."
publish = false
# Required by tauri-plugin's build script, and load-bearing beyond that: it is
# what turns this crate's `cargo:android_library_path` into the
# `DEP_TAURI_PLUGIN_BACK_GESTURE_ANDROID_LIBRARY_PATH` that tauri-build reads
# to add android/ to the app's Gradle project. Drop it and the Kotlin half is
# silently never compiled in.
links = "tauri-plugin-back-gesture"
[lib]
name = "tauri_plugin_back_gesture"
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
@@ -0,0 +1,4 @@
/build
# Copied in from the tauri crate by tauri-plugin's build script on every
# android build; never hand-edited.
/.tauri
@@ -0,0 +1,42 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "ai.praison.mobile.backgesture"
compileSdk = 36
defaultConfig {
minSdk = 24
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 {
// tauri-android declares appcompat/activity as `implementation`, which does
// not reach consumers. Named here because OnBackPressedCallback and
// AppCompatActivity are exactly what this plugin exists to use.
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("androidx.activity:activity-ktx:1.9.3")
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3")
implementation(project(":tauri-android"))
}
@@ -0,0 +1,8 @@
# Consumer rules: applied to the APP that depends on this library, not to the
# library itself. Empty on purpose -- the only class here is @TauriPlugin
# annotated and reached by name from Rust (register_android_plugin), and
# tauri-android's own consumer rules already keep those.
#
# The FILE, however, is not optional: `android/build.gradle.kts` names it in
# consumerProguardFiles, and AGP fails the library build if a file named there
# is missing. It has to exist even with nothing in it.
@@ -0,0 +1,3 @@
# Nothing plugin-specific. The class is reached by name from Rust
# (register_android_plugin), and @TauriPlugin classes are kept by
# tauri-android's own consumer rules.
@@ -0,0 +1,2 @@
include ':tauri-android'
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,78 @@
package ai.praison.mobile.backgesture
import android.app.Activity
import android.webkit.WebView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
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
/**
* The Kotlin half of tauri-plugin-back-gesture.
*
* Owns one OnBackPressedCallback. While Rust has registered a listener (it
* does so during plugin setup), every press is handed to Rust and NOT acted
* on here; Rust decides, and calls `fallBack` if the app declined it. With no
* listener -- Rust setup failed, or the channel is gone -- the press falls
* through immediately, so a broken bridge degrades to a normal back button
* rather than a dead one.
*/
@TauriPlugin
class BackGesturePlugin(private val activity: Activity) : Plugin(activity) {
private val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (hasListener(EVENT)) {
trigger(EVENT, JSObject())
} else {
defer()
}
}
}
/**
* Registered in `load`, not `init`, and that is load-bearing.
*
* OnBackPressedDispatcher consults the MOST RECENTLY ADDED enabled callback
* first. Tauri's AppPlugin adds its own in `init`, at plugin registration;
* plugin registration order between it and this class is whatever the Rust
* plugin store happens to produce. `load` runs once the WebView exists,
* which is after every plugin's `init`, so this callback is always the
* newer one and always wins. Registered in `init` it would win only when
* the store initialised this plugin after Tauri's.
*/
override fun load(webView: WebView) {
val host = activity as AppCompatActivity
host.runOnUiThread { host.onBackPressedDispatcher.addCallback(host, callback) }
}
/** Rust declined the press (or timed out waiting for the webview). */
@Command
fun fallBack(invoke: Invoke) {
activity.runOnUiThread { defer() }
invoke.resolve()
}
/**
* Re-dispatch with this callback disabled, so the next one runs: Tauri's
* AppPlugin (webview history, if any), then the system default. On Android
* 12+ the default moves the task to the back rather than destroying the
* activity, which preserves warm start. `finish()` would destroy it and
* `exitProcess` would kill the process; both are worse.
*/
private fun defer() {
callback.isEnabled = false
try {
(activity as AppCompatActivity).onBackPressedDispatcher.onBackPressed()
} finally {
callback.isEnabled = true
}
}
companion object {
/** Must match `EVT_PRESSED` in src/lib.rs. */
private const val EVENT = "pressed"
}
}
@@ -0,0 +1,15 @@
// No JS-facing commands: the webview never talks to this plugin. The press
// reaches Rust over a Channel and Rust answers the webview through Tauri's own
// event registry, so there is nothing for a capability to grant.
const COMMANDS: &[&str] = &[];
fn main() {
// `android_path` is what gets android/ compiled into the APK: on an
// android target the tauri-plugin build copies the tauri-api into
// android/.tauri and emits `cargo:android_library_path`, which tauri-build
// in the app turns into a Gradle `include`. Off android it only sets the
// `mobile`/`desktop` cfg aliases this crate's `#[cfg]`s rely on.
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.build();
}
@@ -0,0 +1,172 @@
//! Android's system back gesture, delivered to Rust.
//!
//! Tauri already installs an `OnBackPressedCallback` (`AppPlugin.kt`), but it
//! routes the press to *JavaScript plugin listeners* via `Plugin.trigger` --
//! a channel that reaches neither Tauri's event registry nor Rust. The shell's
//! arbitration (`shell::back::Gate` in the app) has to run in Rust, because
//! the webview's answer is fire-and-forget and only Rust can time it out. So
//! this plugin owns its own callback in Kotlin, ahead of Tauri's, and hands
//! each press to Rust over a [`tauri::ipc::Channel`].
//!
//! Two calls, one each way:
//!
//! - Kotlin -> Rust: the user pressed back. Delivered to the `on_press`
//! closure given to [`init`], **on the platform's UI thread** -- the closure
//! must not block, and must not call [`BackGesture::fall_back`] directly.
//! - Rust -> Kotlin: [`BackGesture::fall_back`]. The app declined the press,
//! so the plugin disables its callback and re-dispatches, letting whatever
//! is beneath it act: Tauri's own callback, then the system default, which
//! on Android 12+ moves the task to the back rather than finishing the
//! activity -- warm start survives.
//!
//! On iOS and desktop the plugin is inert: it registers nothing, `on_press`
//! never fires, and `fall_back` does nothing. iOS has no OS-level back, and an
//! iOS app must never terminate itself -- that is an App Review rejection and
//! reads to the user as a crash.
use std::{fmt, sync::Arc};
use tauri::{
plugin::{Builder, TauriPlugin},
AppHandle, Manager, Runtime,
};
/// The plugin name, as `tauri::plugin::Builder` and the ACL know it.
pub const PLUGIN_NAME: &str = "back-gesture";
/// What [`init`] is given: called once per press, on the platform's UI thread.
type OnPress<R> = Arc<dyn Fn(&AppHandle<R>) + Send + Sync>;
/// Java package of the Kotlin half. Must match `package` in
/// `android/src/main/java/ai/praison/mobile/backgesture/BackGesturePlugin.kt`;
/// a mismatch is a `ClassNotFoundException` at startup, not a build error.
#[cfg(target_os = "android")]
const PLUGIN_IDENTIFIER: &str = "ai.praison.mobile.backgesture";
/// The listener event the Kotlin side triggers. Must match `EVENT` there.
#[cfg(target_os = "android")]
const EVT_PRESSED: &str = "pressed";
/// Why a fall-back could not be handed to the platform.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// The Kotlin side rejected or could not be reached.
Platform(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Platform(message) => write!(f, "back-gesture platform call failed: {message}"),
}
}
}
impl std::error::Error for Error {}
/// What the Kotlin `registerListener` command takes. The `Channel` serialises
/// to `__CHANNEL__:<id>` and Tauri's Kotlin deserialiser turns that back into
/// a `Channel` whose `send` lands in the Rust closure.
#[cfg(target_os = "android")]
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct RegisterListenerArgs<'a> {
event: &'a str,
handler: &'a tauri::ipc::Channel<serde_json::Value>,
}
/// The handle to the platform half, managed as Tauri state. Reach it with
/// [`BackGestureExt::back_gesture`].
pub struct BackGesture<R: Runtime> {
#[cfg(target_os = "android")]
handle: tauri::plugin::PluginHandle<R>,
/// Held so the channel outlives setup. Tauri's registry keeps a clone too,
/// but that is an implementation detail this crate should not lean on.
#[cfg(target_os = "android")]
_listener: tauri::ipc::Channel<serde_json::Value>,
#[cfg(not(target_os = "android"))]
_runtime: std::marker::PhantomData<fn() -> R>,
}
impl<R: Runtime> BackGesture<R> {
/// Let the platform act on a press the app declined.
///
/// On Android this blocks until the UI thread has re-dispatched the press,
/// so it must be called from a thread that is not the UI thread -- never
/// from inside the `on_press` closure. Off Android it returns `Ok(())`
/// immediately and does nothing, on purpose; see the crate docs.
pub fn fall_back(&self) -> Result<(), Error> {
#[cfg(target_os = "android")]
{
self.handle
.run_mobile_plugin::<()>("fallBack", ())
.map_err(|e| Error::Platform(e.to_string()))?;
}
Ok(())
}
}
/// `app.back_gesture()` on anything that is a `Manager`.
pub trait BackGestureExt<R: Runtime> {
fn back_gesture(&self) -> &BackGesture<R>;
}
impl<R: Runtime, T: Manager<R>> BackGestureExt<R> for T {
fn back_gesture(&self) -> &BackGesture<R> {
self.state::<BackGesture<R>>().inner()
}
}
/// Build the plugin. `on_press` runs once per system back press, on the
/// platform's UI thread; it must return quickly and must not block on the
/// platform.
pub fn init<R: Runtime>(
on_press: impl Fn(&AppHandle<R>) + Send + Sync + 'static,
) -> TauriPlugin<R> {
let on_press: OnPress<R> = Arc::new(on_press);
Builder::new(PLUGIN_NAME)
.setup(move |app, api| {
#[cfg(target_os = "android")]
let state: BackGesture<R> = {
let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "BackGesturePlugin")?;
let app_handle = app.clone();
let on_press = on_press.clone();
let listener = tauri::ipc::Channel::<serde_json::Value>::new(move |_press| {
on_press(&app_handle);
Ok(())
});
// The base `Plugin` class's own `registerListener` command --
// the same one `addPluginListener` uses from JS -- with a
// Rust-owned channel as the handler. This is the entire
// Kotlin -> Rust path; there is no other supported one.
handle.run_mobile_plugin::<()>(
"registerListener",
RegisterListenerArgs {
event: EVT_PRESSED,
handler: &listener,
},
)?;
BackGesture {
handle,
_listener: listener,
}
};
#[cfg(not(target_os = "android"))]
let state: BackGesture<R> = {
// Nothing to register: no OS-level back on iOS, none on desktop.
let _ = (&on_press, &api);
BackGesture {
_runtime: std::marker::PhantomData,
}
};
app.manage(state);
Ok(())
})
.build()
}
+80 -38
View File
@@ -1,53 +1,95 @@
//! The one thing the webview tells the shell.
//! The back gesture, end to end: the press comes in from the platform, the
//! webview is asked, and its answer — or its silence — decides.
use std::sync::Mutex;
use std::{sync::Mutex, thread, time::Duration};
use crate::shell::back::{Action, Gate};
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri_plugin_back_gesture::BackGestureExt;
use crate::shell::{
back::{Action, Gate, ANSWER_TIMEOUT_MS},
EVT_BACK,
};
/// The back gate, behind a mutex because a press arrives on the platform's
/// thread and the answer arrives on Tauri's.
/// UI thread, the answer on Tauri's, and the timeout on its own.
#[derive(Default)]
pub struct BackState(pub Mutex<Gate>);
/// Run one gate transition. A poisoned mutex means a panic while holding it,
/// and doing nothing is right: falling back on a corrupted gate could exit
/// the app for a press the webview already handled.
fn transition<R: Runtime>(app: &AppHandle<R>, step: impl FnOnce(&mut Gate) -> Action) -> Action {
let state = app.state::<BackState>();
let action = match state.0.lock() {
Ok(mut gate) => step(&mut gate),
Err(_) => Action::Ignore,
};
action
}
/// The platform's system back gesture. Handed to `tauri_plugin_back_gesture`
/// in `lib.rs`; on Android it runs on the UI thread, so nothing here blocks.
///
/// Emitting `back-gesture` is what the TypeScript's `onBackGesture` stack is
/// waiting for; it answers with `back_gesture_result`. If the emit fails, or
/// the webview never answers, the watchdog falls back — silence must not be a
/// dead back button.
pub fn on_back_pressed<R: Runtime>(app: &AppHandle<R>) {
match transition(app, Gate::press) {
Action::Ask => {
if let Err(e) = app.emit(EVT_BACK, ()) {
log::warn!("back: could not emit {EVT_BACK}: {e}");
}
watchdog(app.clone());
}
Action::Drop => log::debug!("back: a press is already pending; dropped"),
// `press` never returns these. Handled rather than `unreachable!()`
// because a panic on the UI thread is a crash, and acting on the
// gate's word is always safe.
Action::FallBack => fall_back(app),
Action::Ignore => {}
}
}
/// The answer may never come — `bridge.invoke` swallows every rejection, so a
/// bundle that failed to load is indistinguishable from one still deciding.
fn watchdog<R: Runtime>(app: AppHandle<R>) {
thread::spawn(move || {
thread::sleep(Duration::from_millis(ANSWER_TIMEOUT_MS));
if transition(&app, Gate::timed_out) == Action::FallBack {
log::debug!("back: no answer within {ANSWER_TIMEOUT_MS}ms; falling back");
fall_back(&app);
}
});
}
/// The webview's answer to a back gesture.
///
/// Answering at all is mandatory on the TypeScript side, and it is fire and
/// forget — `bridge.invoke` swallows failures — so this may never be called for
/// a given press. The watchdog in `shell::back` covers that.
/// forget, so this may never be called for a given press; the watchdog above
/// covers that, and `Gate::answered` ignores an answer that arrives after it.
#[tauri::command]
pub fn back_gesture_result(state: tauri::State<'_, BackState>, handled: bool) {
let action = match state.0.lock() {
Ok(mut gate) => gate.answered(handled),
// A poisoned mutex means a panic while holding it. Doing nothing is
// right: falling back on a corrupted gate could exit the app for a
// press the webview already handled.
Err(_) => Action::Ignore,
};
if action == Action::FallBack {
fall_back();
pub fn back_gesture_result<R: Runtime>(app: AppHandle<R>, handled: bool) {
if transition(&app, |gate| gate.answered(handled)) == Action::FallBack {
fall_back(&app);
}
}
/// Let the platform act on a back press the webview declined.
#[cfg(target_os = "android")]
fn fall_back() {
// Re-dispatch with our callback disabled, so the SYSTEM default runs. On
// Android 12+ that moves the task to the back rather than destroying the
// activity, which preserves warm start and is what a user expects from
// back-at-root. `finish()` destroys it and `exit(0)` kills the process;
// both are worse.
log::debug!("back: deferring to the system default");
}
#[cfg(target_os = "ios")]
fn fall_back() {
// Nothing. iOS has no OS-level back, so this is only reachable via a
// gesture we chose to install — and an iOS app must never terminate
// itself: it is an App Review rejection and reads to the user as a crash.
}
#[cfg(not(mobile))]
fn fall_back() {
// Desktop dev: no platform back to defer to.
/// Let the platform act on a press the webview declined.
///
/// On Android the plugin re-dispatches the press with its own callback
/// disabled, so the SYSTEM default runs — on 12+ that moves the task to the
/// back rather than destroying the activity, which preserves warm start. That
/// call blocks until the UI thread has done it, and this function has callers
/// on the UI thread (via the plugin) and on Tauri's main thread (the command),
/// so it always runs on its own thread. On iOS and desktop the plugin does
/// nothing, by design: see its crate docs.
fn fall_back<R: Runtime>(app: &AppHandle<R>) {
let app = app.clone();
thread::spawn(move || {
if let Err(e) = app.back_gesture().fall_back() {
log::warn!("back: the platform did not take the press: {e}");
}
});
}
+36 -10
View File
@@ -6,7 +6,20 @@
//! to act on the one answer it sends back.
//!
//! The decisions are in `shell::back` and `shell::lifecycle`, both pure and
//! both tested without a device. What is here is the Tauri wiring.
//! both tested without a device. What is here is the Tauri wiring, and what
//! each piece of it emits:
//!
//! - `shell::on_window_event` — `lifecycle` on suspend/resume/focus, and
//! `safe-area-changed` on resize and scale change.
//! - `tauri_plugin_back_gesture` + `commands::on_back_pressed` — `back-gesture`
//! on Android's system back, answered through `back_gesture_result`.
//! - `keyboard-height` — NOT emitted by anything here. The TypeScript reads
//! `visualViewport`, which WKWebView and Android WebView both implement,
//! and treats a native event as an override if one ever arrives.
//!
//! What is honestly not provided: an iOS edge-swipe back (`ShellPort` mentions
//! one; nothing installs the gesture), and the haptics/share plugins the
//! bridge speaks to — those invokes reject and the bridge degrades.
pub mod shell;
@@ -25,17 +38,30 @@ pub mod shell;
/// nobody can read.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![commands::back_gesture_result])
.manage(commands::BackState::default())
.on_window_event(|_window, _event| {
// Lifecycle translation lands here. `shell::lifecycle::phase_for`
// holds the mapping and the reasoning; this is the one line that
// would emit it, and it is left unwired until the mobile targets
// are initialised so there is no dead `emit` on desktop.
})
configure(tauri::Builder::default())
.run(tauri::generate_context!())
.expect("error while running the PraisonAI mobile shell");
}
/// Every piece of wiring the shell needs, applied to a builder.
///
/// Split out of [`run`] so a test can build the SAME app on Tauri's mock
/// runtime. `run` itself is untestable — `generate_context!` wants a built
/// webview bundle, and `.run()` wants the main thread and never returns — and
/// this chain is precisely where a deletion is SILENT. Drop the
/// `LifecycleState` line and `on_window_event` still runs, still finds no
/// state, and drops every phase with a `log::warn!` no phone displays; drop
/// the `on_window_event` line and nothing at all is emitted. Neither shows up
/// as a failure anywhere except on a device, which is why
/// `tests/wiring.rs` builds this and asserts on the result.
pub fn configure<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
builder
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_back_gesture::init(commands::on_back_pressed))
.manage(commands::BackState::default())
.manage(shell::LifecycleState::default())
.invoke_handler(tauri::generate_handler![commands::back_gesture_result])
.on_window_event(shell::on_window_event)
}
pub mod commands;
@@ -2,9 +2,41 @@
//!
//! `WindowEvent::Suspended` / `Resumed` are already surfaced on mobile:
//! `onPause`/`onResume` on Android, `applicationWillResignActive` /
//! `willEnterForeground` on iOS. Only the translation is ours.
//! `willEnterForeground` on iOS. `WindowEvent::Focused` is surfaced on every
//! platform. Only the translation is ours, and it is in three pure pieces:
//!
//! - [`window_event_name`] names the Tauri variant. The mobile-only variants
//! are not constructible on a laptop, which is why the rest of the chain
//! works on strings: it can be tested where the tests run.
//! - [`phase_for`] maps a name to one of the three phases the TypeScript
//! accepts.
//! - [`Tracker`] decides whether that phase is news. The platforms deliver
//! focus and suspension in an order that would otherwise announce
//! `inactive` AFTER `background`, or `active` twice.
/// The phase to report for a Tauri window event, or `None` to say nothing.
use tauri::WindowEvent;
/// A platform-neutral name for the window events the shell cares about.
///
/// `Suspended`/`Resumed` exist only under `cfg(mobile)`; the arms are gated
/// the same way so the desktop dev build compiles, and `cargo check --target
/// aarch64-linux-android` is what proves the mobile arms still match Tauri.
pub fn window_event_name(event: &WindowEvent) -> Option<&'static str> {
match event {
#[cfg(mobile)]
WindowEvent::Suspended => Some("suspended"),
#[cfg(mobile)]
WindowEvent::Resumed => Some("resumed"),
WindowEvent::Focused(true) => Some("focused"),
WindowEvent::Focused(false) => Some("unfocused"),
// Resized, ScaleFactorChanged, Moved, CloseRequested, Destroyed,
// DragDrop, ThemeChanged, and whatever a future Tauri adds
// (`WindowEvent` is non_exhaustive): none of these is a phase.
_ => None,
}
}
/// The phase to report for a named window event, or `None` to say nothing.
///
/// # The mapping decision, stated rather than buried
///
@@ -19,15 +51,67 @@
/// `inactive` would mean the flush never runs and transcripts are lost on every
/// backgrounding.
///
/// The cost is that a control-centre pull-down stops the run loop
/// unnecessarily, and that `inactive` is never emitted from here at all. That
/// is the right trade — a needless pause is recoverable, a lost transcript is
/// not — but it is a trade, and emitting all three needs platform code:
/// `didEnterBackgroundNotification` on iOS, `ProcessLifecycleOwner` on Android.
/// The cost is that a control-centre pull-down or a permission dialog stops
/// the run loop unnecessarily. That is the right trade — a needless pause is
/// recoverable, a lost transcript is not — but it is a trade, and closing it
/// needs platform code: `didEnterBackgroundNotification` on iOS,
/// `ProcessLifecycleOwner` on Android.
///
/// `inactive` comes from focus loss alone: a system dialog over the activity,
/// a window blur on the desktop dev build. `active` is both `resumed` and
/// focus gain, because a control-centre dismissal on iOS is `didBecomeActive`
/// with no `willEnterForeground` before it — without the focus arm, that app
/// would stay reported as backgrounded until the next real suspend/resume.
pub fn phase_for(event: &str) -> Option<&'static str> {
match event {
"suspended" => Some("background"),
"resumed" => Some("active"),
"focused" => Some("active"),
"unfocused" => Some("inactive"),
_ => None,
}
}
/// Decides whether a phase is worth announcing, given what was announced last.
///
/// Two suppressions, each for a real delivery order:
///
/// 1. **`inactive` after `background` is dropped.** Android's `onPause` is
/// followed by `onWindowFocusChanged(false)`; iOS's `willResignActive`
/// is followed by a scene `Focused(false)`. Passing the second through
/// would tell the webview the app had come *up* to inactive when it is
/// in fact still in the background.
/// 2. **A repeat of the current phase is dropped.** `onResume` is followed
/// by `onWindowFocusChanged(true)`, and both say `active`.
///
/// Anything else — including `inactive` → `background`, and `background` →
/// `active` via focus alone — passes through.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Tracker {
current: Option<&'static str>,
}
impl Tracker {
pub fn new() -> Self {
Self::default()
}
/// A named window event happened. `Some(phase)` if the webview should be
/// told; `None` if the event is not a phase or is not news.
pub fn observe(&mut self, event: &str) -> Option<&'static str> {
let phase = phase_for(event)?;
if self.current == Some(phase) {
return None;
}
if phase == "inactive" && self.current == Some("background") {
return None;
}
self.current = Some(phase);
Some(phase)
}
/// The last phase announced, if any.
pub fn current(&self) -> Option<&'static str> {
self.current
}
}
@@ -11,7 +11,13 @@
//! get wrong: the TypeScript subscribes via `plugin:event|listen`, which
//! registers in TAURI'S OWN event registry. Only `Emitter::emit` reaches it. A
//! Tauri mobile *plugin* calling `Plugin.trigger` reaches a completely separate
//! channel — nothing would fire, and there would be no error anywhere.
//! channel — nothing would fire, and there would be no error anywhere. That is
//! exactly why the back gesture goes Kotlin → Channel → Rust → `emit` rather
//! than Kotlin → webview.
use std::sync::{Mutex, PoisonError};
use tauri::{Emitter, Manager, Runtime, Window, WindowEvent};
pub mod back;
pub mod lifecycle;
@@ -24,6 +30,10 @@ pub const EVT_SAFE_AREA: &str = "safe-area-changed";
/// Keyboard height in CSS pixels. `0` is a value, not an absence, and this
/// must fire THROUGH the show/hide transition rather than only at its ends —
/// otherwise the composer teleports while the keyboard slides.
///
/// Nothing native emits this today. The TypeScript reads `visualViewport`,
/// which fires through the transition on both WebViews; the constant stays so
/// a native source can take over without touching the contract.
pub const EVT_KEYBOARD: &str = "keyboard-height";
/// `active` | `inactive` | `background`. An unrecognised phase is dropped by
@@ -36,3 +46,47 @@ pub const EVT_BACK: &str = "back-gesture";
/// What the webview calls to answer a back gesture.
pub const CMD_BACK_RESULT: &str = "back_gesture_result";
/// The lifecycle tracker, managed as Tauri state so `on_window_event` — a
/// plain `Fn` with no captures — can reach it through the window.
#[derive(Debug, Default)]
pub struct LifecycleState(pub Mutex<lifecycle::Tracker>);
/// Translate a Tauri window event into the shell events the webview listens
/// for. Installed with `Builder::on_window_event` in `lib.rs`.
///
/// Emits are best-effort: an emit fails only when there is no webview to
/// deliver to, and there is nothing better to do about that than log it.
pub fn on_window_event<R: Runtime>(window: &Window<R>, event: &WindowEvent) {
if let WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } = event {
// Deliberately an EMPTY payload. Tauri does not expose the insets and
// this crate is not going to guess them; the TypeScript's
// `coerceInsets` treats a payload with no edges as "re-read the CSS
// env() variables", which the WebView has already recomputed by the
// time the resize reaches Rust. Sending zeros instead would be a lie
// the composer would lay out against.
if let Err(e) = window.emit(EVT_SAFE_AREA, serde_json::json!({})) {
log::warn!("shell: could not emit {EVT_SAFE_AREA}: {e}");
}
}
let Some(name) = lifecycle::window_event_name(event) else {
return;
};
// `try_state`, not `state`: a missing manage() is a wiring mistake in
// lib.rs, and a window event is the wrong place to panic about it.
let Some(state) = window.try_state::<LifecycleState>() else {
log::warn!("shell: LifecycleState is not managed; {name} dropped");
return;
};
let phase = state
.0
.lock()
.unwrap_or_else(PoisonError::into_inner)
.observe(name);
if let Some(phase) = phase {
if let Err(e) = window.emit(EVT_LIFECYCLE, phase) {
log::warn!("shell: could not emit {EVT_LIFECYCLE}={phase}: {e}");
}
}
}
@@ -42,3 +42,10 @@ fn an_unknown_window_event_reports_nothing() {
assert_eq!(phase_for("resized"), None);
assert_eq!(phase_for(""), None);
}
#[test]
fn the_back_plugin_name_is_the_one_the_acl_knows() {
// No permission is granted for it (it has no JS commands), but the name
// is still what tauri's plugin store keys on; the crate pins it.
assert_eq!(tauri_plugin_back_gesture::PLUGIN_NAME, "back-gesture");
}
@@ -0,0 +1,109 @@
//! The lifecycle translation, event by event and sequence by sequence.
//!
//! The strings asserted here are the ones `coercePhase` in
//! `adapters/src/tauri/shell.ts` accepts: `active`, `inactive`, `background`.
//! Anything else is DROPPED by the TypeScript rather than defaulted, so a
//! misspelling here would not throw anywhere — the app would simply never
//! flush on backgrounding again.
use praisonai_mobile_lib::shell::lifecycle::{phase_for, window_event_name, Tracker};
use tauri::{PhysicalSize, WindowEvent};
/// Exactly what `LIFECYCLE_PHASES` in shell.ts lists.
const ACCEPTED_BY_TYPESCRIPT: [&str; 3] = ["active", "inactive", "background"];
#[test]
fn every_relevant_window_event_maps_to_a_phase_the_typescript_accepts() {
// The full set of names `window_event_name` can produce, each pinned to
// its exact phase. Adding a name there without adding it here fails.
let expected = [
("suspended", "background"),
("resumed", "active"),
("focused", "active"),
("unfocused", "inactive"),
];
for (event, phase) in expected {
assert_eq!(phase_for(event), Some(phase), "phase_for({event:?})");
assert!(
ACCEPTED_BY_TYPESCRIPT.contains(&phase),
"{phase:?} is not a LifecyclePhase the TypeScript accepts"
);
}
}
#[test]
fn the_variants_constructible_on_a_laptop_are_named() {
// Suspended/Resumed exist only under cfg(mobile) and cannot be built
// here; the android cross-check in CI compiles those arms. Focus can.
assert_eq!(window_event_name(&WindowEvent::Focused(true)), Some("focused"));
assert_eq!(window_event_name(&WindowEvent::Focused(false)), Some("unfocused"));
}
#[test]
fn geometry_and_teardown_are_not_phases() {
// Resized is what drives safe-area-changed; it must not also announce a
// lifecycle phase, or every rotation would look like a resume.
// ScaleFactorChanged is not constructible here (non_exhaustive variant);
// it takes the same `_ => None` arm as Resized.
assert_eq!(window_event_name(&WindowEvent::Resized(PhysicalSize::new(1, 1))), None);
assert_eq!(window_event_name(&WindowEvent::Destroyed), None);
}
// ---- sequences the platforms actually deliver -------------------------------
#[test]
fn android_home_press_announces_background_once_and_not_inactive_after_it() {
// onPause, then onWindowFocusChanged(false). The second must not be
// reported: it would tell the webview the app had come UP to inactive.
let mut t = Tracker::new();
assert_eq!(t.observe("focused"), Some("active"));
assert_eq!(t.observe("suspended"), Some("background"));
assert_eq!(t.observe("unfocused"), None);
assert_eq!(t.current(), Some("background"));
}
#[test]
fn android_return_announces_active_once_for_resume_plus_focus() {
// onResume, then onWindowFocusChanged(true): both say active.
let mut t = Tracker::new();
t.observe("suspended");
assert_eq!(t.observe("resumed"), Some("active"));
assert_eq!(t.observe("focused"), None);
}
#[test]
fn focus_loss_before_suspension_reports_both_in_order() {
// The other order the platforms use. inactive -> background is real
// progress and both must arrive, in that order.
let mut t = Tracker::new();
t.observe("focused");
assert_eq!(t.observe("unfocused"), Some("inactive"));
assert_eq!(t.observe("suspended"), Some("background"));
}
#[test]
fn ios_control_centre_dismissal_returns_to_active_through_focus_alone() {
// willResignActive suspends; the pull-down dismissal is didBecomeActive
// with NO willEnterForeground. Without the focus arm the app would stay
// reported as backgrounded.
let mut t = Tracker::new();
t.observe("focused");
assert_eq!(t.observe("suspended"), Some("background"));
assert_eq!(t.observe("focused"), Some("active"));
}
#[test]
fn a_system_dialog_is_inactive_then_active() {
let mut t = Tracker::new();
t.observe("focused");
assert_eq!(t.observe("unfocused"), Some("inactive"));
assert_eq!(t.observe("focused"), Some("active"));
}
#[test]
fn a_name_that_is_not_a_phase_is_ignored_and_does_not_disturb_the_state() {
let mut t = Tracker::new();
t.observe("suspended");
assert_eq!(t.observe("resized"), None);
assert_eq!(t.current(), Some("background"));
}
@@ -0,0 +1,175 @@
//! The wiring, which is the half of this crate no pure test can reach.
//!
//! `shell/lifecycle.rs` and `shell/back.rs` are pure and thoroughly tested.
//! But a phone sees none of that unless three things are true at once:
//! `on_window_event` is REGISTERED, `LifecycleState` is MANAGED, and the
//! translation actually reaches `Emitter::emit`. Every one of those is silent
//! when it breaks — a missing `manage` logs a warning no device shows, a
//! missing `on_window_event` emits nothing and reports nothing — so mutation
//! testing found all of them surviving the pure suite untouched.
//!
//! These tests build the SAME builder chain the app runs (`configure`) on
//! Tauri's mock runtime, and push a real `Window` through the real handler.
use std::sync::mpsc;
use std::time::Duration;
use praisonai_mobile_lib::{
commands::BackState,
configure,
shell::{self, LifecycleState, EVT_LIFECYCLE, EVT_SAFE_AREA},
};
use tauri::{
test::{mock_builder, mock_context, noop_assets, MockRuntime},
Listener, Manager, PhysicalSize, WindowEvent,
};
/// The app exactly as `run()` builds it, minus the parts that need a device.
fn app() -> tauri::App<MockRuntime> {
configure(mock_builder())
.build(mock_context(noop_assets()))
.expect("the shell's builder chain must produce an app")
}
fn window(app: &tauri::App<MockRuntime>) -> tauri::Window<MockRuntime> {
tauri::webview::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
.build()
.expect("a mock window")
.as_ref()
.window()
}
/// Collect the payloads of one event as they are emitted.
fn subscribe(app: &tauri::App<MockRuntime>, event: &str) -> mpsc::Receiver<String> {
let (tx, rx) = mpsc::channel();
app.listen(event, move |e| {
let _ = tx.send(e.payload().to_string());
});
rx
}
fn next(rx: &mpsc::Receiver<String>) -> Option<String> {
rx.recv_timeout(Duration::from_secs(2)).ok()
}
// ---- the builder chain -----------------------------------------------------
#[test]
fn the_app_manages_the_state_the_window_handler_reaches_for() {
// Without this, `on_window_event` takes its `try_state` bail-out and every
// lifecycle phase is dropped with a log line -- on a phone, invisibly.
let app = app();
assert!(
app.try_state::<LifecycleState>().is_some(),
"LifecycleState is not managed; every lifecycle event would be dropped"
);
assert!(
app.try_state::<BackState>().is_some(),
"BackState is not managed; the back gate would panic on the first press"
);
}
// ---- the window-event handler ----------------------------------------------
#[test]
fn a_focus_change_emits_the_lifecycle_phase_the_typescript_listens_for() {
// The whole point of the crate: a Tauri window event has to come out of
// `Emitter::emit` under the contract name, or the webview hears nothing.
let app = app();
let win = window(&app);
let rx = subscribe(&app, EVT_LIFECYCLE);
shell::on_window_event(&win, &WindowEvent::Focused(true));
assert_eq!(
next(&rx).as_deref(),
Some("\"active\""),
"focus gain must emit {EVT_LIFECYCLE} = active"
);
shell::on_window_event(&win, &WindowEvent::Focused(false));
assert_eq!(
next(&rx).as_deref(),
Some("\"inactive\""),
"focus loss must emit {EVT_LIFECYCLE} = inactive"
);
}
#[test]
fn a_repeated_phase_is_not_re_emitted_through_the_real_handler() {
// The Tracker's suppression is unit-tested; this proves the handler
// actually consults the SHARED state rather than a fresh one per event.
// With a per-call Tracker every one of these would emit.
let app = app();
let win = window(&app);
let rx = subscribe(&app, EVT_LIFECYCLE);
shell::on_window_event(&win, &WindowEvent::Focused(true));
assert_eq!(next(&rx).as_deref(), Some("\"active\""));
shell::on_window_event(&win, &WindowEvent::Focused(true));
assert_eq!(
next(&rx),
None,
"the same phase twice must be announced once; the state is not shared"
);
}
#[test]
fn a_resize_emits_the_safe_area_event_with_an_empty_payload() {
// Empty on purpose: Tauri does not expose the insets, and the TypeScript's
// `coerceInsets` reads a payload with no edges as "re-read the CSS
// env() variables". Sending zeros would be a lie the composer lays out
// against, so the payload shape is as load-bearing as the name.
let app = app();
let win = window(&app);
let rx = subscribe(&app, EVT_SAFE_AREA);
shell::on_window_event(&win, &WindowEvent::Resized(PhysicalSize::new(390, 844)));
assert_eq!(
next(&rx).as_deref(),
Some("{}"),
"a resize must emit {EVT_SAFE_AREA} with an empty payload"
);
}
#[test]
fn a_resize_is_not_also_announced_as_a_lifecycle_phase() {
// Every rotation would otherwise look like a resume.
let app = app();
let win = window(&app);
let rx = subscribe(&app, EVT_LIFECYCLE);
shell::on_window_event(&win, &WindowEvent::Resized(PhysicalSize::new(390, 844)));
assert_eq!(next(&rx), None, "a resize is not a lifecycle phase");
}
// ---- the one line the mock runtime cannot check ----------------------------
#[test]
fn run_registers_the_window_event_handler() {
// Tauri's MockRuntime accepts `on_window_event` and DROPS the callback
// (test/mock_runtime.rs: the closure is never stored), so no mock app can
// observe whether the handler is registered -- and deleting that one line
// leaves every other test in this file passing while a device goes
// completely silent. A source assertion is the only gate available, so
// this is that gate, stated plainly rather than left as a hole.
const LIB_RS: &str = include_str!("../src/lib.rs");
for required in [
".on_window_event(shell::on_window_event)",
".manage(shell::LifecycleState::default())",
".manage(commands::BackState::default())",
"tauri_plugin_back_gesture::init(commands::on_back_pressed)",
] {
assert!(
LIB_RS.contains(required),
"src/lib.rs no longer contains `{required}` -- the shell would be \
silent on a device with every other test still green"
);
}
// And that `run` goes through the chain the tests above build.
assert!(
LIB_RS.contains("configure(tauri::Builder::default())"),
"run() no longer builds the app through configure(), so tests/wiring.rs \
is testing a chain the app does not use"
);
}