feat(desktop): add native app integrations (#1715)

This commit is contained in:
Carlos Florêncio
2026-07-15 23:19:00 +01:00
committed by GitHub
parent 4f717be720
commit d763b8c0c9
88 changed files with 7764 additions and 231 deletions
+14 -2
View File
@@ -9,7 +9,8 @@ Run from `apps/` unless noted:
- `pnpm --filter @kandev/desktop build:vite` builds the startup surface.
- `pnpm --filter @kandev/desktop build` builds the Linux desktop bundle locally.
- `pnpm --filter @kandev/desktop e2e` builds the app and runs the Linux smoke harness under Xvfb when needed.
- From `apps/desktop/src-tauri`, `cargo test` runs Rust unit tests without requiring the Tauri runtime feature by default.
- From `apps/desktop/src-tauri`, `cargo test --features desktop-runtime` runs the complete Rust
suite, including native command/plugin integration.
## Runtime Resources
@@ -30,9 +31,20 @@ Use `scripts/release/prepare-desktop-runtime.sh` and `scripts/release/verify-des
- Frontend code in `src/` is only the startup/error surface. The real product UI is still served by the Go backend after `/health` succeeds.
- Rust code owns backend process spawning and cleanup. Do not expose broad shell or filesystem permissions to frontend JavaScript.
- Native menus emit versioned `kandev-desktop-v1-*` events for SPA-owned context and navigation.
Updater, notification, and external-link operations use narrow generated Tauri commands scoped
to the owned loopback WebView; do not grant the SPA direct plugin permissions.
- Desktop launches force `KANDEV_SERVER_HOST=127.0.0.1`, prefer a stable desktop port with random fallback, and pass `KANDEV_BUNDLE_DIR` to the native launcher.
- Desktop launches set `KANDEV_DESKTOP_NATIVE_NOTIFICATIONS=true` so the owned backend suppresses
only its duplicate System notification provider.
- `KANDEV_DESKTOP_RUNTIME_DIR` is a test/development override for runtime resources.
## Release
Desktop artifacts are built in `.github/workflows/release.yml` after the platform runtime bundles. macOS and Windows signing is automatic: complete signing/notarization secrets produce signed artifacts; missing or incomplete inputs produce unsigned desktop artifacts with a release-notes warning. Use `desktop_validation_only=true` for artifact-only validation runs that skip the release PR, tag, publish jobs, and public container tags.
Desktop artifacts are built in `.github/workflows/release.yml` after the platform runtime bundles.
macOS and Windows signing is automatic: complete signing/notarization secrets produce signed
artifacts; missing or incomplete inputs produce unsigned desktop artifacts with a release-notes
warning. A separate Tauri updater key signs `.app.tar.gz`, `.AppImage.tar.gz`, and NSIS updater
bundles and enables `latest.json`; unsigned installers are never advertised by the updater. Use
`desktop_validation_only=true` for artifact-only validation runs that skip the release PR, tag,
publish jobs, and public container tags.
@@ -13,6 +13,8 @@ import {
const __dirname = dirname(fileURLToPath(import.meta.url));
const backendRsPath = resolve(__dirname, "../src-tauri/src/backend.rs");
const mainRsPath = resolve(__dirname, "../src-tauri/src/main.rs");
const shellRsPath = resolve(__dirname, "../src-tauri/src/shell.rs");
async function withTempDir(run) {
const dir = await mkdtemp(join(tmpdir(), "wait-for-file-"));
@@ -96,3 +98,36 @@ test("health-requested timeout stays above the Rust backend's own HEALTH_TIMEOUT
"ROOT_REQUESTED_TIMEOUT_MS must be a positive integer",
);
});
test("Close Context owns Cmd/Ctrl+W without a native window-close fallback", async () => {
const [mainSource, shellSource] = await Promise.all([
readFile(mainRsPath, "utf8"),
readFile(shellRsPath, "utf8"),
]);
assert.match(mainSource, /MENU_CLOSE_CONTEXT, "Close Context"[\s\S]*CmdOrCtrl\+KeyW/);
assert.match(
shellSource,
/MENU_CLOSE_CONTEXT\s*=>\s*Some\(MenuAction::Emit\(CLOSE_CONTEXT_EVENT\)\)/,
);
assert.doesNotMatch(mainSource, /PredefinedMenuItem::close_window/);
assert.doesNotMatch(mainSource, /\.accelerator\("CmdOrCtrl\+KeyW"\)[\s\S]{0,160}shutdown_and_exit/);
});
test("generic app activation never consumes a pending notification route", async () => {
const mainSource = await readFile(mainRsPath, "utf8");
assert.doesNotMatch(mainSource, /emit_pending_notification_route/);
assert.match(
mainSource,
/tauri_plugin_single_instance::init\([\s\S]*activate_main_window/,
);
assert.match(mainSource, /RunEvent::Reopen[\s\S]*activate_main_window/);
});
test("fullscreen uses the platform-native desktop accelerators", async () => {
const mainSource = await readFile(mainRsPath, "utf8");
assert.match(mainSource, /target_os = "macos"[\s\S]*"Ctrl\+Cmd\+F"/);
assert.match(mainSource, /not\(target_os = "macos"\)[\s\S]*"F11"/);
});
+553 -2
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -444,6 +453,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -540,6 +566,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -674,6 +709,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -960,6 +1006,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1254,8 +1310,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1277,8 +1335,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -1546,6 +1607,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1664,6 +1741,25 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[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"
@@ -1774,17 +1870,25 @@ dependencies = [
name = "kandev-desktop"
version = "0.78.0"
dependencies = [
"base64 0.22.1",
"darling",
"getrandom 0.3.4",
"idna_adapter",
"libc",
"minisign-verify",
"notify-rust",
"plist",
"serde",
"serde_json",
"serde_with",
"tauri",
"tauri-build",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-runtime",
"url",
]
[[package]]
@@ -1880,12 +1984,32 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2",
"objc2-foundation",
"time",
"uuid",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -1949,6 +2073,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2033,6 +2163,20 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "notify-rust"
version = "4.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2231,6 +2375,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2288,6 +2444,17 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2304,6 +2471,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2698,6 +2879,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg 0.10.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.46"
@@ -2730,7 +2967,7 @@ dependencies = [
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
"rand_pcg",
"rand_pcg 0.2.1",
]
[[package]]
@@ -2744,6 +2981,27 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -2764,6 +3022,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -2782,6 +3050,21 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -2800,6 +3083,15 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2889,16 +3181,21 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -2908,6 +3205,21 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
@@ -2938,6 +3250,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3249,7 +3596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3418,6 +3765,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3522,6 +3875,17 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -3643,6 +4007,63 @@ 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-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.5",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecee219f11cdac713ab32959db5d0cceec4810ba4f4458da992292ecf9660321"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"url",
"windows",
"zbus",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.2.1"
@@ -3658,6 +4079,38 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.9.0"
@@ -3761,6 +4214,17 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -3865,6 +4329,21 @@ dependencies = [
"time-core",
]
[[package]]
name = "tinyvec"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
@@ -3879,6 +4358,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4192,6 +4681,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4391,6 +4886,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.5"
@@ -4447,6 +4952,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4677,6 +5191,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5017,6 +5540,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "zbus"
version = "5.13.2"
@@ -5098,6 +5631,24 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+14
View File
@@ -14,10 +14,17 @@ path = "src/main.rs"
required-features = ["desktop-runtime"]
test = false
[[bin]]
name = "updater-signature-verifier"
path = "src/bin/updater_signature_verifier.rs"
[features]
default = []
desktop-runtime = [
"dep:tauri",
"dep:tauri-plugin-opener",
"dep:tauri-plugin-updater",
"dep:tauri-plugin-notification",
"dep:tauri-plugin-single-instance",
"dep:tauri-runtime",
]
@@ -26,13 +33,20 @@ desktop-runtime = [
tauri-build = { version = "=2.4.1", features = [] }
[dependencies]
base64 = "0.22"
darling = "=0.21.3"
getrandom = "0.3"
idna_adapter = "=1.0.0"
libc = "0.2"
minisign-verify = "=0.2.5"
plist = "=1.10.0"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
serde_with = "=3.15.0"
tauri = { version = "=2.8.4", features = [], optional = true }
tauri-runtime = { version = "=2.9.0", optional = true }
tauri-plugin-single-instance = { version = "=2.2.1", optional = true }
tauri-plugin-updater = { version = "=2.9.0", optional = true }
tauri-plugin-notification = { version = "=2.3.3", optional = true }
tauri-plugin-opener = { version = "=2.4.0", optional = true }
url = "2"
+9 -1
View File
@@ -1,5 +1,13 @@
fn main() {
if std::env::var_os("CARGO_FEATURE_DESKTOP_RUNTIME").is_some() {
tauri_build::build();
let manifest = tauri_build::AppManifest::new().commands(&[
"get_update_state",
"check_for_updates",
"install_update",
"show_native_notification",
"open_external_url",
]);
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
.expect("failed to build Tauri desktop application");
}
}
@@ -1,7 +1,19 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Main Kandev desktop window permissions",
"description": "Desktop bridge; privileged commands verify the exact owned backend origin",
"local": false,
"remote": {
"urls": ["http://127.0.0.1:*"]
},
"windows": ["main"],
"permissions": ["core:app:default", "core:event:default", "core:window:default"]
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"allow-get-update-state",
"allow-check-for-updates",
"allow-install-update",
"allow-show-native-notification",
"allow-open-external-url"
]
}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Main Kandev desktop window permissions","local":true,"windows":["main"],"permissions":["core:app:default","core:event:default","core:window:default"]}}
{"default":{"identifier":"default","description":"Desktop bridge; privileged commands verify the exact owned backend origin","remote":{"urls":["http://127.0.0.1:*"]},"local":false,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-get-update-state","allow-check-for-updates","allow-install-update","allow-show-native-notification","allow-open-external-url"]}}
@@ -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": {
@@ -176,6 +344,66 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Enables the check_for_updates command without any pre-configured scope.",
"type": "string",
"const": "allow-check-for-updates",
"markdownDescription": "Enables the check_for_updates command without any pre-configured scope."
},
{
"description": "Enables the get_update_state command without any pre-configured scope.",
"type": "string",
"const": "allow-get-update-state",
"markdownDescription": "Enables the get_update_state command without any pre-configured scope."
},
{
"description": "Enables the install_update command without any pre-configured scope.",
"type": "string",
"const": "allow-install-update",
"markdownDescription": "Enables the install_update command without any pre-configured scope."
},
{
"description": "Enables the open_external_url command without any pre-configured scope.",
"type": "string",
"const": "allow-open-external-url",
"markdownDescription": "Enables the open_external_url command without any pre-configured scope."
},
{
"description": "Enables the show_native_notification command without any pre-configured scope.",
"type": "string",
"const": "allow-show-native-notification",
"markdownDescription": "Enables the show_native_notification command without any pre-configured scope."
},
{
"description": "Denies the check_for_updates command without any pre-configured scope.",
"type": "string",
"const": "deny-check-for-updates",
"markdownDescription": "Denies the check_for_updates command without any pre-configured scope."
},
{
"description": "Denies the get_update_state command without any pre-configured scope.",
"type": "string",
"const": "deny-get-update-state",
"markdownDescription": "Denies the get_update_state command without any pre-configured scope."
},
{
"description": "Denies the install_update command without any pre-configured scope.",
"type": "string",
"const": "deny-install-update",
"markdownDescription": "Denies the install_update command without any pre-configured scope."
},
{
"description": "Denies the open_external_url command without any pre-configured scope.",
"type": "string",
"const": "deny-open-external-url",
"markdownDescription": "Denies the open_external_url command without any pre-configured scope."
},
{
"description": "Denies the show_native_notification command without any pre-configured scope.",
"type": "string",
"const": "deny-show-native-notification",
"markdownDescription": "Denies the show_native_notification command without any pre-configured scope."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
@@ -2119,6 +2347,306 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show 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."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
}
]
},
@@ -2215,6 +2743,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": {
@@ -176,6 +344,66 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Enables the check_for_updates command without any pre-configured scope.",
"type": "string",
"const": "allow-check-for-updates",
"markdownDescription": "Enables the check_for_updates command without any pre-configured scope."
},
{
"description": "Enables the get_update_state command without any pre-configured scope.",
"type": "string",
"const": "allow-get-update-state",
"markdownDescription": "Enables the get_update_state command without any pre-configured scope."
},
{
"description": "Enables the install_update command without any pre-configured scope.",
"type": "string",
"const": "allow-install-update",
"markdownDescription": "Enables the install_update command without any pre-configured scope."
},
{
"description": "Enables the open_external_url command without any pre-configured scope.",
"type": "string",
"const": "allow-open-external-url",
"markdownDescription": "Enables the open_external_url command without any pre-configured scope."
},
{
"description": "Enables the show_native_notification command without any pre-configured scope.",
"type": "string",
"const": "allow-show-native-notification",
"markdownDescription": "Enables the show_native_notification command without any pre-configured scope."
},
{
"description": "Denies the check_for_updates command without any pre-configured scope.",
"type": "string",
"const": "deny-check-for-updates",
"markdownDescription": "Denies the check_for_updates command without any pre-configured scope."
},
{
"description": "Denies the get_update_state command without any pre-configured scope.",
"type": "string",
"const": "deny-get-update-state",
"markdownDescription": "Denies the get_update_state command without any pre-configured scope."
},
{
"description": "Denies the install_update command without any pre-configured scope.",
"type": "string",
"const": "deny-install-update",
"markdownDescription": "Denies the install_update command without any pre-configured scope."
},
{
"description": "Denies the open_external_url command without any pre-configured scope.",
"type": "string",
"const": "deny-open-external-url",
"markdownDescription": "Denies the open_external_url command without any pre-configured scope."
},
{
"description": "Denies the show_native_notification command without any pre-configured scope.",
"type": "string",
"const": "deny-show-native-notification",
"markdownDescription": "Denies the show_native_notification command without any pre-configured scope."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
@@ -2119,6 +2347,306 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show 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."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
}
]
},
@@ -2215,6 +2743,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,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-check-for-updates"
description = "Enables the check_for_updates command without any pre-configured scope."
commands.allow = ["check_for_updates"]
[[permission]]
identifier = "deny-check-for-updates"
description = "Denies the check_for_updates command without any pre-configured scope."
commands.deny = ["check_for_updates"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-get-update-state"
description = "Enables the get_update_state command without any pre-configured scope."
commands.allow = ["get_update_state"]
[[permission]]
identifier = "deny-get-update-state"
description = "Denies the get_update_state command without any pre-configured scope."
commands.deny = ["get_update_state"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-install-update"
description = "Enables the install_update command without any pre-configured scope."
commands.allow = ["install_update"]
[[permission]]
identifier = "deny-install-update"
description = "Denies the install_update command without any pre-configured scope."
commands.deny = ["install_update"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-open-external-url"
description = "Enables the open_external_url command without any pre-configured scope."
commands.allow = ["open_external_url"]
[[permission]]
identifier = "deny-open-external-url"
description = "Denies the open_external_url command without any pre-configured scope."
commands.deny = ["open_external_url"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-show-native-notification"
description = "Enables the show_native_notification command without any pre-configured scope."
commands.allow = ["show_native_notification"]
[[permission]]
identifier = "deny-show-native-notification"
description = "Denies the show_native_notification command without any pre-configured scope."
commands.deny = ["show_native_notification"]
+213 -25
View File
@@ -13,6 +13,7 @@ use std::{
thread,
time::{Duration, Instant},
};
use url::Url;
#[cfg(feature = "desktop-runtime")]
use tauri::{AppHandle, Manager, WebviewWindow};
@@ -22,6 +23,7 @@ const LOOPBACK_HOST: &str = "127.0.0.1";
const DEFAULT_DESKTOP_PORT: u16 = 38430;
const DESKTOP_PORT_ENV: &str = "KANDEV_DESKTOP_PORT";
const DESKTOP_HEALTH_TOKEN_ENV: &str = "KANDEV_DESKTOP_HEALTH_TOKEN";
const DESKTOP_NATIVE_NOTIFICATIONS_ENV: &str = "KANDEV_DESKTOP_NATIVE_NOTIFICATIONS";
const DESKTOP_HEALTH_TOKEN_HEADER: &str = "x-kandev-desktop-health-token";
const STARTUP_OUTPUT_LIMIT: usize = 12 * 1024;
const HEALTH_READY_SETTLE: Duration = Duration::from_millis(100);
@@ -37,6 +39,7 @@ pub struct BackendState {
child: Arc<Mutex<Option<Child>>>,
startup_output: Arc<Mutex<StartupOutput>>,
shutdown_started: Arc<AtomicBool>,
owned_origin: Arc<Mutex<Option<String>>>,
}
impl Default for BackendState {
@@ -45,6 +48,7 @@ impl Default for BackendState {
child: Arc::new(Mutex::new(None)),
startup_output: Arc::new(Mutex::new(StartupOutput::default())),
shutdown_started: Arc::new(AtomicBool::new(false)),
owned_origin: Arc::new(Mutex::new(None)),
}
}
}
@@ -56,7 +60,12 @@ impl BackendState {
pub fn stop(&self) {
self.shutdown_started.store(true, Ordering::SeqCst);
let child = self.child.lock().expect("backend child mutex poisoned").take();
self.clear_owned_origin();
let child = self
.child
.lock()
.expect("backend child mutex poisoned")
.take();
if let Some(mut child) = child {
terminate_child(&mut child);
}
@@ -66,6 +75,55 @@ impl BackendState {
self.shutdown_started.load(Ordering::SeqCst)
}
pub fn set_owned_origin(&self, backend_url: &str) -> Result<(), String> {
let origin = loopback_origin(backend_url)?;
*self
.owned_origin
.lock()
.expect("desktop origin mutex poisoned") = Some(origin);
Ok(())
}
pub fn accepts_url(&self, url: &str) -> bool {
let expected = self
.owned_origin
.lock()
.expect("desktop origin mutex poisoned")
.clone();
expected.is_some_and(|origin| same_origin(&origin, url))
}
#[cfg(feature = "desktop-runtime")]
pub fn require_owned_origin(&self, webview: &WebviewWindow) -> Result<(), String> {
if !self.has_live_child() {
self.clear_owned_origin();
return Err("desktop backend is no longer running".to_string());
}
let url = webview
.url()
.map_err(|err| format!("could not read desktop WebView URL: {err}"))?;
if self.accepts_url(url.as_str()) {
Ok(())
} else {
Err("desktop command is only available from the Kandev backend".to_string())
}
}
fn clear_owned_origin(&self) {
*self
.owned_origin
.lock()
.expect("desktop origin mutex poisoned") = None;
}
fn has_live_child(&self) -> bool {
self.child
.lock()
.expect("backend child mutex poisoned")
.as_mut()
.is_some_and(|child| matches!(child.try_wait(), Ok(None)))
}
fn set_child(&self, mut child: Child) -> bool {
let mut guard = self.child.lock().expect("backend child mutex poisoned");
if self.is_shutdown_started() {
@@ -160,7 +218,11 @@ pub fn start_desktop_backend(app: AppHandle, window: WebviewWindow) {
);
match launch_and_wait(&app, &state) {
Ok(url) => {
if let Err(err) = navigate_to_backend(&window, &url) {
if let Err(err) = state
.set_owned_origin(&url)
.and_then(|_| navigate_to_backend(&window, &url))
{
state.clear_owned_origin();
state.stop();
set_status(
&window,
@@ -168,6 +230,8 @@ pub fn start_desktop_backend(app: AppHandle, window: WebviewWindow) {
&format!("Backend started, but the window could not navigate: {err}"),
true,
);
} else {
crate::updater::start_automatic_checks(app);
}
}
Err(err) => {
@@ -178,6 +242,23 @@ pub fn start_desktop_backend(app: AppHandle, window: WebviewWindow) {
});
}
fn loopback_origin(input: &str) -> Result<String, String> {
let url = Url::parse(input).map_err(|err| format!("invalid desktop backend URL: {err}"))?;
if url.scheme() != "http"
|| url.host_str() != Some(LOOPBACK_HOST)
|| url.port_or_known_default().is_none()
{
return Err("desktop backend URL must use a loopback HTTP origin".to_string());
}
Ok(url.origin().ascii_serialization())
}
fn same_origin(expected: &str, input: &str) -> bool {
Url::parse(input)
.map(|url| url.origin().ascii_serialization() == expected)
.unwrap_or(false)
}
#[cfg(feature = "desktop-runtime")]
fn launch_and_wait(app: &AppHandle, state: &BackendState) -> Result<String, String> {
let runtime_dir = resolve_runtime_dir(app)?;
@@ -228,7 +309,12 @@ pub fn build_backend_command(
let program = runtime_dir.join("bin").join(executable_name("kandev"));
let cwd = program
.parent()
.ok_or_else(|| format!("Kandev launcher has no parent directory: {}", program.display()))?
.ok_or_else(|| {
format!(
"Kandev launcher has no parent directory: {}",
program.display()
)
})?
.to_path_buf();
Ok(BackendCommandSpec {
program,
@@ -244,8 +330,14 @@ pub fn build_backend_command(
pub fn validate_runtime_dir(runtime_dir: &Path) -> Result<(), String> {
let bin_dir = runtime_dir.join("bin");
require_runtime_file(&bin_dir.join(executable_name("kandev")), "Kandev launcher binary")?;
require_runtime_file(&bin_dir.join(executable_name("agentctl")), "agentctl binary")?;
require_runtime_file(
&bin_dir.join(executable_name("kandev")),
"Kandev launcher binary",
)?;
require_runtime_file(
&bin_dir.join(executable_name("agentctl")),
"agentctl binary",
)?;
for &(name, label) in REMOTE_AGENTCTL_HELPERS.iter() {
require_runtime_file(&bin_dir.join(name), label)?;
}
@@ -266,6 +358,11 @@ pub fn desktop_environment(
home_dir: Option<&Path>,
) -> BTreeMap<OsString, OsString> {
let path = normalized_path(env.get(OsStr::new("PATH")), home_dir);
env.retain(|key, _| {
!key
.to_string_lossy()
.eq_ignore_ascii_case(DESKTOP_NATIVE_NOTIFICATIONS_ENV)
});
env.insert(
OsString::from("KANDEV_SERVER_HOST"),
OsString::from(LOOPBACK_HOST),
@@ -274,6 +371,10 @@ pub fn desktop_environment(
OsString::from("KANDEV_BUNDLE_DIR"),
runtime_dir.as_os_str().to_os_string(),
);
env.insert(
OsString::from(DESKTOP_NATIVE_NOTIFICATIONS_ENV),
OsString::from("true"),
);
env.insert(OsString::from("PATH"), path);
env
}
@@ -319,7 +420,9 @@ fn preferred_desktop_port(value: Option<OsString>) -> Result<u16, String> {
.parse::<u16>()
.map_err(|_| format!("{DESKTOP_PORT_ENV} must be a TCP port between 1 and 65535"))?;
if port == 0 {
Err(format!("{DESKTOP_PORT_ENV} must be a TCP port between 1 and 65535"))
Err(format!(
"{DESKTOP_PORT_ENV} must be a TCP port between 1 and 65535"
))
} else {
Ok(port)
}
@@ -332,7 +435,9 @@ fn pick_available_loopback_port(preferred: u16) -> Result<u16, String> {
.map(|addr| addr.port())
.map_err(|err| err.to_string()),
Err(err) if err.kind() == ErrorKind::AddrInUse => pick_loopback_port(),
Err(err) => Err(format!("Could not reserve {LOOPBACK_HOST}:{preferred}: {err}")),
Err(err) => Err(format!(
"Could not reserve {LOOPBACK_HOST}:{preferred}: {err}"
)),
}
}
@@ -395,7 +500,9 @@ fn wait_for_backend(
return Err("Desktop startup cancelled".to_string());
}
if let Some(message) = state.child_exit_message()? {
return Err(format!("Kandev launcher exited before /health became ready ({message})"));
return Err(format!(
"Kandev launcher exited before /health became ready ({message})"
));
}
if health_ready(port, expected_health_token) {
thread::sleep(HEALTH_READY_SETTLE);
@@ -646,7 +753,10 @@ mod tests {
)
.expect("command spec");
assert_eq!(spec.program, runtime_dir.join("bin").join(executable_name("kandev")));
assert_eq!(
spec.program,
runtime_dir.join("bin").join(executable_name("kandev"))
);
assert_eq!(
spec.args,
vec![
@@ -655,7 +765,10 @@ mod tests {
OsString::from("48123"),
]
);
assert_eq!(spec.env.get(OsStr::new("CUSTOM_ENV")), Some(&OsString::from("kept")));
assert_eq!(
spec.env.get(OsStr::new("CUSTOM_ENV")),
Some(&OsString::from("kept"))
);
assert_eq!(
spec.env.get(OsStr::new("KANDEV_SERVER_HOST")),
Some(&OsString::from(LOOPBACK_HOST))
@@ -668,6 +781,11 @@ mod tests {
spec.env.get(OsStr::new(DESKTOP_HEALTH_TOKEN_ENV)),
Some(&OsString::from("health-token"))
);
assert_eq!(
spec.env
.get(OsStr::new("KANDEV_DESKTOP_NATIVE_NOTIFICATIONS")),
Some(&OsString::from("true"))
);
}
#[test]
@@ -686,11 +804,28 @@ mod tests {
assert!(entries.contains(&home_dir.join(".local/bin")));
}
#[test]
fn desktop_environment_replaces_notification_flag_case_insensitively() {
let mut inherited = BTreeMap::new();
inherited.insert(
OsString::from("kandev_desktop_native_notifications"),
OsString::from("false"),
);
let env = desktop_environment(Path::new("/opt/kandev"), inherited, None);
assert_eq!(
env.get(OsStr::new(DESKTOP_NATIVE_NOTIFICATIONS_ENV)),
Some(&OsString::from("true"))
);
assert!(!env.contains_key(OsStr::new("kandev_desktop_native_notifications")));
}
#[test]
fn missing_launcher_returns_readable_error() {
let dir = temp_root("missing-launcher");
let err =
build_backend_command(&dir, 48123, BTreeMap::new(), None).expect_err("missing launcher");
let err = build_backend_command(&dir, 48123, BTreeMap::new(), None)
.expect_err("missing launcher");
assert!(err.contains("Kandev launcher binary is missing"), "{err}");
}
@@ -701,8 +836,8 @@ mod tests {
fs::create_dir_all(&bin).expect("create bin");
fs::write(bin.join(executable_name("kandev")), b"stub").expect("write launcher");
let err =
build_backend_command(&dir, 48123, BTreeMap::new(), None).expect_err("missing agentctl");
let err = build_backend_command(&dir, 48123, BTreeMap::new(), None)
.expect_err("missing agentctl");
assert!(err.contains("agentctl binary is missing"), "{err}");
}
@@ -718,7 +853,10 @@ mod tests {
let err =
build_backend_command(&dir, 48123, BTreeMap::new(), None).expect_err("missing helper");
assert!(err.contains("agentctl linux/amd64 helper is missing"), "{err}");
assert!(
err.contains("agentctl linux/amd64 helper is missing"),
"{err}"
);
}
#[test]
@@ -735,7 +873,10 @@ mod tests {
let err =
build_backend_command(&dir, 48123, BTreeMap::new(), None).expect_err("missing helper");
assert!(err.contains("agentctl darwin/arm64 helper is missing"), "{err}");
assert!(
err.contains("agentctl darwin/arm64 helper is missing"),
"{err}"
);
}
#[test]
@@ -815,6 +956,38 @@ mod tests {
assert!(state.is_shutdown_started());
}
#[test]
fn desktop_commands_require_the_exact_owned_backend_origin() {
let state = BackendState::default();
state
.set_owned_origin("http://127.0.0.1:38430")
.expect("set owned backend origin");
assert!(state.accepts_url("http://127.0.0.1:38430/settings"));
assert!(!state.accepts_url("http://127.0.0.1:38431/settings"));
assert!(!state.accepts_url("http://localhost:38430/settings"));
assert!(!state.accepts_url("https://127.0.0.1:38430/settings"));
}
#[test]
fn desktop_commands_are_denied_before_backend_origin_is_set() {
assert!(!BackendState::default().accepts_url("http://127.0.0.1:38430"));
}
#[cfg(unix)]
#[test]
fn live_child_is_recognized_as_running() {
let state = BackendState::default();
let child = Command::new("sh")
.args(["-c", "sleep 1"])
.spawn()
.expect("start child");
assert!(state.set_child(child));
assert!(state.has_live_child());
state.stop();
}
#[test]
fn launcher_exit_message_includes_recent_output() {
let message = launcher_exit_message("exit status: 1", Some("database failed".to_string()));
@@ -838,7 +1011,11 @@ mod tests {
fn capture_stream_retries_interrupted_reads() {
let output = Arc::new(Mutex::new(StartupOutput::default()));
capture_stream("stdout", InterruptedThenData::new(b"backend ready"), output.clone());
capture_stream(
"stdout",
InterruptedThenData::new(b"backend ready"),
output.clone(),
);
let deadline = Instant::now() + Duration::from_secs(1);
while Instant::now() < deadline {
@@ -875,8 +1052,16 @@ mod tests {
fn response_header_check_requires_matching_desktop_health_token() {
let response = b"HTTP/1.1 200 OK\r\nX-Kandev-Desktop-Health-Token: token\r\n\r\n";
assert!(response_has_header(response, DESKTOP_HEALTH_TOKEN_HEADER, "token"));
assert!(!response_has_header(response, DESKTOP_HEALTH_TOKEN_HEADER, "other-token"));
assert!(response_has_header(
response,
DESKTOP_HEALTH_TOKEN_HEADER,
"token"
));
assert!(!response_has_header(
response,
DESKTOP_HEALTH_TOKEN_HEADER,
"other-token"
));
assert!(!response_has_header(
b"HTTP/1.1 200 OK\r\n\r\n",
DESKTOP_HEALTH_TOKEN_HEADER,
@@ -901,7 +1086,10 @@ mod tests {
assert!(state.set_child(child));
state.stop();
assert!(!process_exists(pid), "backend child {pid} should be terminated");
assert!(
!process_exists(pid),
"backend child {pid} should be terminated"
);
}
#[cfg(unix)]
@@ -921,7 +1109,10 @@ mod tests {
state.stop();
assert!(!state.set_child(child));
assert!(!process_exists(pid), "backend child {pid} should be terminated");
assert!(
!process_exists(pid),
"backend child {pid} should be terminated"
);
}
fn temp_runtime_dir(name: &str) -> PathBuf {
@@ -937,10 +1128,7 @@ mod tests {
}
fn temp_root(name: &str) -> PathBuf {
let dir = env::temp_dir().join(format!(
"kandev-desktop-{name}-{}",
std::process::id()
));
let dir = env::temp_dir().join(format!("kandev-desktop-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp root");
dir
@@ -0,0 +1,69 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use minisign_verify::{PublicKey, Signature};
use std::{env, fs, path::Path};
fn main() {
if let Err(error) = run(env::args().skip(1).collect()) {
eprintln!("{error}");
std::process::exit(1);
}
}
fn run(arguments: Vec<String>) -> Result<(), String> {
if arguments.len() < 3 || arguments.len() % 2 == 0 {
return Err(
"usage: updater-signature-verifier <public-key> <artifact> <signature> [...]"
.to_string(),
);
}
let public_key = decode_text(&arguments[0], "updater public key")?;
let public_key = PublicKey::decode(&public_key)
.map_err(|error| format!("invalid updater public key: {error}"))?;
for pair in arguments[1..].chunks_exact(2) {
verify_pair(&public_key, Path::new(&pair[0]), Path::new(&pair[1]))?;
}
Ok(())
}
fn verify_pair(
public_key: &PublicKey,
artifact: &Path,
signature_path: &Path,
) -> Result<(), String> {
let bytes = fs::read(artifact).map_err(|error| {
format!(
"could not read updater artifact {}: {error}",
artifact.display()
)
})?;
let encoded_signature = fs::read_to_string(signature_path).map_err(|error| {
format!(
"could not read updater signature {}: {error}",
signature_path.display()
)
})?;
let signature_text = decode_text(encoded_signature.trim(), "updater signature")?;
let signature = Signature::decode(&signature_text).map_err(|error| {
format!(
"invalid updater signature {}: {error}",
signature_path.display()
)
})?;
public_key
.verify(&bytes, &signature, true)
.map_err(|error| {
format!(
"updater signature does not match embedded public key for {}: {error}",
artifact.display()
)
})
}
fn decode_text(encoded: &str, label: &str) -> Result<String, String> {
let bytes = STANDARD
.decode(encoded)
.map_err(|error| format!("invalid base64 {label}: {error}"))?;
String::from_utf8(bytes).map_err(|error| format!("invalid UTF-8 {label}: {error}"))
}
@@ -0,0 +1,146 @@
use url::{Host, Url};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalLinkError {
Empty,
Malformed,
UnsupportedScheme,
CredentialsNotAllowed,
MissingDestination,
LocalDestination,
}
impl std::fmt::Display for ExternalLinkError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self {
Self::Empty => "the URL is empty",
Self::Malformed => "the URL is malformed",
Self::UnsupportedScheme => "the URL scheme is not allowed",
Self::CredentialsNotAllowed => "credentials in URLs are not allowed",
Self::MissingDestination => "the URL has no destination",
Self::LocalDestination => "local URLs must remain inside Kandev",
};
formatter.write_str(message)
}
}
pub fn validate_external_url(input: &str) -> Result<Url, ExternalLinkError> {
if input.is_empty() || input.trim() != input {
return Err(ExternalLinkError::Empty);
}
let parsed = Url::parse(input).map_err(|_| ExternalLinkError::Malformed)?;
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ExternalLinkError::CredentialsNotAllowed);
}
match parsed.scheme() {
"http" | "https" => validate_web_url(parsed),
"mailto" if !parsed.path().is_empty() => Ok(parsed),
"mailto" => Err(ExternalLinkError::MissingDestination),
_ => Err(ExternalLinkError::UnsupportedScheme),
}
}
fn validate_web_url(parsed: Url) -> Result<Url, ExternalLinkError> {
let host = parsed.host().ok_or(ExternalLinkError::MissingDestination)?;
let is_local = match host {
Host::Domain(domain) => {
let domain = domain.trim_end_matches('.');
domain.eq_ignore_ascii_case("localhost")
|| domain
.to_ascii_lowercase()
.strip_suffix(".localhost")
.is_some()
}
Host::Ipv4(address) => address.is_loopback() || address.is_unspecified(),
Host::Ipv6(address) => {
address.is_loopback()
|| address.is_unspecified()
|| address
.to_ipv4_mapped()
.is_some_and(|mapped| mapped.is_loopback() || mapped.is_unspecified())
}
};
if is_local {
Err(ExternalLinkError::LocalDestination)
} else {
Ok(parsed)
}
}
#[cfg(feature = "desktop-runtime")]
pub fn open_validated_external_url(app: &tauri::AppHandle, input: &str) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
let url = validate_external_url(input).map_err(|error| error.to_string())?;
app.opener()
.open_url(url.as_str(), None::<&str>)
.map_err(|error| format!("failed to open external URL: {error}"))
}
#[cfg(feature = "desktop-runtime")]
#[tauri::command]
pub fn open_external_url(
app: tauri::AppHandle,
backend: tauri::State<'_, crate::backend::BackendState>,
webview: tauri::WebviewWindow,
url: String,
) -> Result<(), String> {
backend.require_owned_origin(&webview)?;
open_validated_external_url(&app, &url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permits_external_web_and_mail_destinations() {
assert!(validate_external_url("https://github.com/kdlbs/kandev").is_ok());
assert!(validate_external_url("http://example.com/docs?q=desktop").is_ok());
assert!(validate_external_url("mailto:support@example.com?subject=Kandev").is_ok());
}
#[test]
fn rejects_unsupported_malformed_and_credentialed_destinations() {
assert_eq!(
validate_external_url("javascript:alert(1)"),
Err(ExternalLinkError::UnsupportedScheme)
);
assert_eq!(
validate_external_url("https://user:secret@example.com"),
Err(ExternalLinkError::CredentialsNotAllowed)
);
assert_eq!(
validate_external_url("not a URL"),
Err(ExternalLinkError::Malformed)
);
assert_eq!(
validate_external_url("mailto:"),
Err(ExternalLinkError::MissingDestination)
);
}
#[test]
fn keeps_local_web_destinations_inside_kandev() {
for input in [
"http://localhost:8080/task",
"http://preview.localhost:3000",
"http://localhost.:8080",
"http://preview.localhost.:3000",
"http://127.0.0.1:4242",
"http://127.9.8.7",
"http://[::1]:4000",
"http://[::ffff:127.0.0.1]:4000",
"http://0.0.0.0:8080",
"http://[::]:8080",
] {
assert_eq!(
validate_external_url(input),
Err(ExternalLinkError::LocalDestination),
"{input}"
);
}
}
}
+5
View File
@@ -1 +1,6 @@
pub mod backend;
pub mod external_links;
pub mod native_notifications;
pub mod shell;
pub mod updater;
pub mod window_state;
+291 -19
View File
@@ -1,41 +1,87 @@
use kandev_desktop::backend;
use kandev_desktop::{
backend, external_links,
native_notifications::{self, NativeNotificationState},
shell::{self, MenuAction, ZoomState},
updater::{self, UpdaterState},
window_state::WindowStateStore,
};
use std::thread;
use tauri::{Manager, RunEvent, WindowEvent};
use tauri::{
menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu},
Emitter, Manager, RunEvent, WindowEvent,
};
const MAIN_WINDOW_LABEL: &str = "main";
const WINDOW_STATE_FILE: &str = "window-state-v1.json";
#[cfg(target_os = "macos")]
const FULLSCREEN_ACCELERATOR: &str = "Ctrl+Cmd+F";
#[cfg(not(target_os = "macos"))]
const FULLSCREEN_ACCELERATOR: &str = "F11";
fn main() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(
tauri_plugin_opener::Builder::new()
.open_js_links_on_click(false)
.build(),
)
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
activate_main_window(app);
}))
.manage(backend::BackendState::default())
.manage(UpdaterState::new(env!("CARGO_PKG_VERSION")))
.manage(NativeNotificationState::default())
.manage(ZoomState::default())
.invoke_handler(tauri::generate_handler![
updater::get_update_state,
updater::check_for_updates,
updater::install_update,
native_notifications::show_native_notification,
external_links::open_external_url,
])
.menu(build_menu)
.on_menu_event(handle_menu_event)
.setup(|app| {
let window = app
.get_webview_window("main")
.get_webview_window(MAIN_WINDOW_LABEL)
.expect("main window should exist");
let state_path = app.path().app_data_dir()?.join(WINDOW_STATE_FILE);
let window_state = WindowStateStore::new(state_path);
if let Err(err) = window_state.restore(&window) {
eprintln!("Could not restore desktop window state: {err}");
}
if let Err(err) = window_state.save(&window) {
eprintln!("Could not initialize desktop window state: {err}");
}
app.manage(window_state);
window.show()?;
backend::start_desktop_backend(app.handle().clone(), window);
Ok(())
})
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
let state = window.app_handle().state::<backend::BackendState>().inner().clone();
if state.begin_shutdown() {
api.prevent_close();
let window = window.clone();
thread::spawn(move || {
state.stop();
let _ = window.close();
});
.on_window_event(|window, event| match event {
WindowEvent::Moved(_) | WindowEvent::Resized(_) => {
if let Some(webview) = window.app_handle().get_webview_window(window.label()) {
if let Some(state) = window.try_state::<WindowStateStore>() {
if let Err(err) = state.schedule_save(&webview) {
eprintln!("Could not schedule desktop window state: {err}");
}
}
}
}
WindowEvent::CloseRequested { api, .. } => {
if shutdown_and_exit(window.app_handle()) {
api.prevent_close();
}
}
_ => {}
})
.build(tauri::generate_context!())
.expect("error while building Kandev desktop app");
app.run(|app_handle, event| {
if let RunEvent::ExitRequested { api, .. } = event {
app.run(|app_handle, event| match event {
RunEvent::ExitRequested { code, api, .. } if code.is_none() => {
let state = app_handle.state::<backend::BackendState>().inner().clone();
if state.begin_shutdown() {
api.prevent_exit();
@@ -46,5 +92,231 @@ fn main() {
});
}
}
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => activate_main_window(app_handle),
_ => {}
});
}
fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let settings = MenuItemBuilder::with_id(shell::MENU_SETTINGS, "Settings...")
.accelerator("CmdOrCtrl+Comma")
.build(app)?;
let check_updates =
MenuItemBuilder::with_id(shell::MENU_CHECK_FOR_UPDATES, "Check for Updates...")
.build(app)?;
let quit = MenuItemBuilder::with_id(shell::MENU_QUIT, "Quit Kandev")
.accelerator("CmdOrCtrl+KeyQ")
.build(app)?;
let app_menu = build_application_menu(app, &settings, &check_updates, &quit)?;
let new_task = MenuItemBuilder::with_id(shell::MENU_NEW_TASK, "New Task")
.accelerator("CmdOrCtrl+KeyN")
.build(app)?;
let close_context = MenuItemBuilder::with_id(shell::MENU_CLOSE_CONTEXT, "Close Context")
.accelerator("CmdOrCtrl+KeyW")
.build(app)?;
let file_menu = Submenu::with_items(app, "File", true, &[&new_task, &close_context])?;
let edit_menu = Submenu::with_items(
app,
"Edit",
true,
&[
&PredefinedMenuItem::undo(app, None)?,
&PredefinedMenuItem::redo(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::cut(app, None)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
],
)?;
let zoom_in = MenuItemBuilder::with_id(shell::MENU_ZOOM_IN, "Zoom In")
.accelerator("CmdOrCtrl+Shift+Equal")
.build(app)?;
let zoom_in_equals = MenuItemBuilder::with_id(shell::MENU_ZOOM_IN_EQUALS, "Zoom In (=)")
.accelerator("CmdOrCtrl+Equal")
.build(app)?;
let zoom_out = MenuItemBuilder::with_id(shell::MENU_ZOOM_OUT, "Zoom Out")
.accelerator("CmdOrCtrl+Minus")
.build(app)?;
let actual_size = MenuItemBuilder::with_id(shell::MENU_ZOOM_RESET, "Actual Size")
.accelerator("CmdOrCtrl+Digit0")
.build(app)?;
let fullscreen = MenuItemBuilder::with_id(shell::MENU_FULLSCREEN, "Toggle Full Screen")
.accelerator(FULLSCREEN_ACCELERATOR)
.build(app)?;
let view_menu = Submenu::with_items(
app,
"View",
true,
&[
&zoom_in,
&zoom_in_equals,
&zoom_out,
&actual_size,
&PredefinedMenuItem::separator(app)?,
&fullscreen,
],
)?;
let window_menu = Submenu::with_items(
app,
"Window",
true,
&[
&PredefinedMenuItem::minimize(app, None)?,
&PredefinedMenuItem::maximize(app, None)?,
],
)?;
let docs =
MenuItemBuilder::with_id(shell::MENU_HELP_DOCS, "Kandev Documentation").build(app)?;
let repository =
MenuItemBuilder::with_id(shell::MENU_HELP_REPOSITORY, "Kandev Repository").build(app)?;
let releases =
MenuItemBuilder::with_id(shell::MENU_HELP_RELEASES, "Release Notes").build(app)?;
let help_menu = Submenu::with_items(app, "Help", true, &[&docs, &repository, &releases])?;
Menu::with_items(
app,
&[
&app_menu,
&file_menu,
&edit_menu,
&view_menu,
&window_menu,
&help_menu,
],
)
}
#[cfg(target_os = "macos")]
fn build_application_menu(
app: &tauri::AppHandle,
settings: &tauri::menu::MenuItem<tauri::Wry>,
check_updates: &tauri::menu::MenuItem<tauri::Wry>,
quit: &tauri::menu::MenuItem<tauri::Wry>,
) -> tauri::Result<Submenu<tauri::Wry>> {
Submenu::with_items(
app,
"Kandev",
true,
&[
&PredefinedMenuItem::about(app, None, None)?,
&PredefinedMenuItem::separator(app)?,
settings,
check_updates,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::services(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::hide(app, None)?,
&PredefinedMenuItem::hide_others(app, None)?,
&PredefinedMenuItem::separator(app)?,
quit,
],
)
}
#[cfg(not(target_os = "macos"))]
fn build_application_menu(
app: &tauri::AppHandle,
settings: &tauri::menu::MenuItem<tauri::Wry>,
check_updates: &tauri::menu::MenuItem<tauri::Wry>,
quit: &tauri::menu::MenuItem<tauri::Wry>,
) -> tauri::Result<Submenu<tauri::Wry>> {
Submenu::with_items(
app,
"Kandev",
true,
&[
&PredefinedMenuItem::about(app, None, None)?,
&PredefinedMenuItem::separator(app)?,
settings,
check_updates,
&PredefinedMenuItem::separator(app)?,
quit,
],
)
}
fn handle_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) {
let Some(action) = shell::menu_action(event.id().as_ref()) else {
return;
};
match action {
MenuAction::Emit(event_name) => {
focus_main_window(app);
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.emit(event_name, ());
}
}
MenuAction::ZoomIn | MenuAction::ZoomOut | MenuAction::ZoomReset => {
apply_zoom(app, action);
}
MenuAction::Fullscreen => {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
if let Ok(fullscreen) = window.is_fullscreen() {
let _ = window.set_fullscreen(!fullscreen);
}
}
}
MenuAction::Quit => {
let _ = shutdown_and_exit(app);
}
MenuAction::HelpDocs => open_help_url(app, "https://github.com/kdlbs/kandev#readme"),
MenuAction::HelpRepository => open_help_url(app, "https://github.com/kdlbs/kandev"),
MenuAction::HelpReleases => open_help_url(app, "https://github.com/kdlbs/kandev/releases"),
}
}
fn open_help_url(app: &tauri::AppHandle, url: &str) {
if let Err(error) = external_links::open_validated_external_url(app, url) {
eprintln!("Could not open desktop help URL: {error}");
}
}
fn apply_zoom(app: &tauri::AppHandle, action: MenuAction) {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
let zoom = app.state::<ZoomState>();
let Some(level) = zoom.preview(action) else {
return;
};
if window.set_zoom(level).is_ok() {
zoom.commit(level);
}
}
fn focus_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
fn activate_main_window(app: &tauri::AppHandle) {
focus_main_window(app);
}
fn shutdown_and_exit(app: &tauri::AppHandle) -> bool {
let state = app.state::<backend::BackendState>().inner().clone();
if !state.begin_shutdown() {
return false;
}
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
if let Some(window_state) = app.try_state::<WindowStateStore>() {
let _ = window_state.save(&window);
}
}
let app = app.clone();
thread::spawn(move || {
state.stop();
app.exit(0);
});
true
}
@@ -0,0 +1,225 @@
use serde::{Deserialize, Serialize};
use std::{
collections::VecDeque,
sync::{
atomic::{AtomicBool, Ordering},
Mutex,
},
};
const MAX_SEEN_EVENT_IDS: usize = 1_024;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeNotificationRequest {
pub event_id: String,
pub title: String,
pub body: String,
pub task_id: String,
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NativeNotificationResult {
Shown,
Duplicate,
PermissionDenied,
}
#[derive(Debug, Default)]
pub struct NativeNotificationState {
seen_event_ids: Mutex<VecDeque<String>>,
permission_prompt_attempted: AtomicBool,
}
impl NativeNotificationState {
fn claim(&self, event_id: &str) -> bool {
let mut seen = self
.seen_event_ids
.lock()
.expect("notification identity mutex poisoned");
if seen.iter().any(|seen_id| seen_id == event_id) {
return false;
}
if seen.len() == MAX_SEEN_EVENT_IDS {
seen.pop_front();
}
seen.push_back(event_id.to_string());
true
}
fn release(&self, event_id: &str) {
self.seen_event_ids
.lock()
.expect("notification identity mutex poisoned")
.retain(|seen_id| seen_id != event_id);
}
fn claim_after_permission(
&self,
request: &NativeNotificationRequest,
permission_granted: bool,
) -> NativeNotificationResult {
if !permission_granted {
return NativeNotificationResult::PermissionDenied;
}
if self.claim(&request.event_id) {
NativeNotificationResult::Shown
} else {
NativeNotificationResult::Duplicate
}
}
fn begin_permission_prompt(&self) -> bool {
!self
.permission_prompt_attempted
.swap(true, Ordering::SeqCst)
}
fn allow_permission_prompt_retry(&self) {
self.permission_prompt_attempted
.store(false, Ordering::SeqCst);
}
}
fn validate_request(request: &NativeNotificationRequest) -> Result<(), String> {
if !request.event_id.starts_with("session.waiting_for_input:")
&& !request.event_id.starts_with("session.failed:")
{
return Err("unsupported native notification event".to_string());
}
if request.event_id.len() > 256
|| request.title.is_empty()
|| request.title.len() > 160
|| request.body.len() > 1000
|| request.task_id.is_empty()
|| request.task_id.len() > 256
|| request.session_id.as_ref().is_some_and(|id| id.len() > 256)
{
return Err("invalid native notification payload".to_string());
}
Ok(())
}
#[cfg(feature = "desktop-runtime")]
#[tauri::command]
pub fn show_native_notification(
app: tauri::AppHandle,
state: tauri::State<'_, NativeNotificationState>,
backend: tauri::State<'_, crate::backend::BackendState>,
webview: tauri::WebviewWindow,
request: NativeNotificationRequest,
) -> Result<NativeNotificationResult, String> {
use tauri::plugin::PermissionState;
use tauri_plugin_notification::NotificationExt;
backend.require_owned_origin(&webview)?;
validate_request(&request)?;
let mut permission = app
.notification()
.permission_state()
.map_err(|err| err.to_string())?;
if permission != PermissionState::Granted
&& permission != PermissionState::Denied
&& state.begin_permission_prompt()
{
permission = app.notification().request_permission().map_err(|err| {
state.allow_permission_prompt_retry();
err.to_string()
})?;
}
let claim_result =
state.claim_after_permission(&request, permission == PermissionState::Granted);
if claim_result != NativeNotificationResult::Shown {
return Ok(claim_result);
}
let show_result = app
.notification()
.builder()
.title(&request.title)
.body(&request.body)
.show();
if let Err(err) = show_result {
state.release(&request.event_id);
return Err(err.to_string());
}
Ok(NativeNotificationResult::Shown)
}
#[cfg(test)]
mod tests {
use super::*;
fn request(event_id: &str, task_id: &str) -> NativeNotificationRequest {
NativeNotificationRequest {
event_id: event_id.to_string(),
title: "Task needs your input".to_string(),
body: "Waiting".to_string(),
task_id: task_id.to_string(),
session_id: Some("session-1".to_string()),
}
}
#[test]
fn event_identity_is_delivered_at_most_once() {
let state = NativeNotificationState::default();
let request = request("session.waiting_for_input:session-1", "task-1");
assert!(state.claim(&request.event_id));
assert!(!state.claim(&request.event_id));
}
#[test]
fn event_identity_cache_is_bounded() {
let state = NativeNotificationState::default();
for index in 0..=MAX_SEEN_EVENT_IDS {
assert!(state.claim(&format!("session.failed:{index}")));
}
assert!(state.claim("session.failed:0"));
}
#[test]
fn bridge_rejects_events_outside_the_two_notification_types() {
let request = request("office.inbox_item:item-1", "task-1");
assert_eq!(
validate_request(&request),
Err("unsupported native notification event".to_string())
);
}
#[test]
fn denied_delivery_does_not_claim_the_event() {
let state = NativeNotificationState::default();
let request = request("session.failed:session-1", "task-1");
assert_eq!(
state.claim_after_permission(&request, false),
NativeNotificationResult::PermissionDenied
);
assert_eq!(
state.claim_after_permission(&request, true),
NativeNotificationResult::Shown
);
}
#[test]
fn permission_prompt_is_attempted_only_once_per_process() {
let state = NativeNotificationState::default();
assert!(state.begin_permission_prompt());
assert!(!state.begin_permission_prompt());
}
#[test]
fn permission_prompt_can_retry_after_a_transient_error() {
let state = NativeNotificationState::default();
assert!(state.begin_permission_prompt());
state.allow_permission_prompt_retry();
assert!(state.begin_permission_prompt());
}
}
+158
View File
@@ -0,0 +1,158 @@
use std::sync::Mutex;
pub const DESKTOP_EVENT_PREFIX: &str = "kandev-desktop-v1-";
pub const CLOSE_CONTEXT_EVENT: &str = "kandev-desktop-v1-close-context";
pub const OPEN_SETTINGS_EVENT: &str = "kandev-desktop-v1-open-settings";
pub const NEW_TASK_EVENT: &str = "kandev-desktop-v1-new-task";
pub const CHECK_FOR_UPDATES_EVENT: &str = "kandev-desktop-v1-check-for-updates";
pub const MENU_SETTINGS: &str = "desktop.v1.open-settings";
pub const MENU_NEW_TASK: &str = "desktop.v1.new-task";
pub const MENU_CLOSE_CONTEXT: &str = "desktop.v1.close-context";
pub const MENU_CHECK_FOR_UPDATES: &str = "desktop.v1.check-for-updates";
pub const MENU_ZOOM_IN: &str = "desktop.v1.zoom-in";
pub const MENU_ZOOM_IN_EQUALS: &str = "desktop.v1.zoom-in-equals";
pub const MENU_ZOOM_OUT: &str = "desktop.v1.zoom-out";
pub const MENU_ZOOM_RESET: &str = "desktop.v1.zoom-reset";
pub const MENU_FULLSCREEN: &str = "desktop.v1.fullscreen";
pub const MENU_QUIT: &str = "desktop.v1.quit";
pub const MENU_HELP_DOCS: &str = "desktop.v1.help-docs";
pub const MENU_HELP_REPOSITORY: &str = "desktop.v1.help-repository";
pub const MENU_HELP_RELEASES: &str = "desktop.v1.help-releases";
const DEFAULT_ZOOM: f64 = 1.0;
const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM: f64 = 0.5;
const MAX_ZOOM: f64 = 2.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuAction {
Emit(&'static str),
ZoomIn,
ZoomOut,
ZoomReset,
Fullscreen,
Quit,
HelpDocs,
HelpRepository,
HelpReleases,
}
pub fn menu_action(id: &str) -> Option<MenuAction> {
match id {
MENU_SETTINGS => Some(MenuAction::Emit(OPEN_SETTINGS_EVENT)),
MENU_NEW_TASK => Some(MenuAction::Emit(NEW_TASK_EVENT)),
MENU_CLOSE_CONTEXT => Some(MenuAction::Emit(CLOSE_CONTEXT_EVENT)),
MENU_CHECK_FOR_UPDATES => Some(MenuAction::Emit(CHECK_FOR_UPDATES_EVENT)),
MENU_ZOOM_IN | MENU_ZOOM_IN_EQUALS => Some(MenuAction::ZoomIn),
MENU_ZOOM_OUT => Some(MenuAction::ZoomOut),
MENU_ZOOM_RESET => Some(MenuAction::ZoomReset),
MENU_FULLSCREEN => Some(MenuAction::Fullscreen),
MENU_QUIT => Some(MenuAction::Quit),
MENU_HELP_DOCS => Some(MenuAction::HelpDocs),
MENU_HELP_REPOSITORY => Some(MenuAction::HelpRepository),
MENU_HELP_RELEASES => Some(MenuAction::HelpReleases),
_ => None,
}
}
#[derive(Debug)]
pub struct ZoomState {
level: Mutex<f64>,
}
impl Default for ZoomState {
fn default() -> Self {
Self {
level: Mutex::new(DEFAULT_ZOOM),
}
}
}
impl ZoomState {
pub fn current(&self) -> f64 {
*self.level.lock().expect("zoom state mutex poisoned")
}
pub fn preview(&self, action: MenuAction) -> Option<f64> {
let current = self.current();
match action {
MenuAction::ZoomIn => Some(clamp_zoom(current + ZOOM_STEP)),
MenuAction::ZoomOut => Some(clamp_zoom(current - ZOOM_STEP)),
MenuAction::ZoomReset => Some(DEFAULT_ZOOM),
_ => None,
}
}
pub fn commit(&self, level: f64) {
*self.level.lock().expect("zoom state mutex poisoned") = clamp_zoom(level);
}
}
fn clamp_zoom(level: f64) -> f64 {
level.clamp(MIN_ZOOM, MAX_ZOOM)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contextual_close_maps_only_to_the_versioned_bridge_event() {
assert_eq!(
menu_action(MENU_CLOSE_CONTEXT),
Some(MenuAction::Emit(CLOSE_CONTEXT_EVENT))
);
assert!(CLOSE_CONTEXT_EVENT.starts_with(DESKTOP_EVENT_PREFIX));
}
#[test]
fn native_commands_map_to_frozen_v1_events() {
assert_eq!(CLOSE_CONTEXT_EVENT, "kandev-desktop-v1-close-context");
assert_eq!(OPEN_SETTINGS_EVENT, "kandev-desktop-v1-open-settings");
assert_eq!(NEW_TASK_EVENT, "kandev-desktop-v1-new-task");
assert_eq!(
CHECK_FOR_UPDATES_EVENT,
"kandev-desktop-v1-check-for-updates"
);
assert_eq!(
menu_action(MENU_SETTINGS),
Some(MenuAction::Emit(OPEN_SETTINGS_EVENT))
);
assert_eq!(
menu_action(MENU_NEW_TASK),
Some(MenuAction::Emit(NEW_TASK_EVENT))
);
assert_eq!(
menu_action(MENU_CHECK_FOR_UPDATES),
Some(MenuAction::Emit(CHECK_FOR_UPDATES_EVENT))
);
}
#[test]
fn zoom_clamps_and_reset_restores_actual_size() {
let state = ZoomState::default();
for _ in 0..30 {
let level = state.preview(MenuAction::ZoomIn).unwrap();
state.commit(level);
}
assert_eq!(state.current(), MAX_ZOOM);
for _ in 0..30 {
let level = state.preview(MenuAction::ZoomOut).unwrap();
state.commit(level);
}
assert_eq!(state.current(), MIN_ZOOM);
state.commit(state.preview(MenuAction::ZoomReset).unwrap());
assert_eq!(state.current(), DEFAULT_ZOOM);
}
#[test]
fn failed_zoom_can_leave_the_previous_level_uncommitted() {
let state = ZoomState::default();
assert_eq!(state.preview(MenuAction::ZoomIn), Some(1.1));
assert_eq!(state.current(), DEFAULT_ZOOM);
}
}
+690
View File
@@ -0,0 +1,690 @@
use serde::Serialize;
use std::{path::Path, sync::Mutex, time::Duration};
#[cfg(feature = "desktop-runtime")]
use crate::backend::BackendState;
#[cfg(feature = "desktop-runtime")]
use std::time::{Instant, SystemTime, UNIX_EPOCH};
#[cfg(feature = "desktop-runtime")]
use tauri::{AppHandle, Manager, State, WebviewWindow};
#[cfg(feature = "desktop-runtime")]
use tauri_plugin_updater::{Update, UpdaterExt};
pub const AUTOMATIC_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const LINUX_INSTALL_UNSUPPORTED: &str = "Automatic update installation is available only when Kandev is running from an AppImage. For .deb or .rpm installations, download the latest package from Release notes and update it with your package manager.";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum UpdatePhase {
Idle,
Checking,
Available,
UpToDate,
Downloading,
Installing,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSnapshot {
pub phase: UpdatePhase,
pub current_version: String,
pub latest_version: Option<String>,
pub release_notes: Option<String>,
pub release_url: Option<String>,
pub checked_at_epoch_ms: Option<u64>,
pub downloaded_bytes: Option<u64>,
pub total_bytes: Option<u64>,
pub install_supported: bool,
pub install_unsupported_reason: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InstallSupport {
supported: bool,
unsupported_reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UpdatePlatform {
Linux,
MacOs,
Windows,
Other,
}
struct InstallEnvironment<'a> {
appimage: Option<&'a Path>,
appdir: Option<&'a Path>,
current_exe: Option<&'a Path>,
temp_dir: &'a Path,
appimage_is_file: bool,
}
impl InstallSupport {
fn supported() -> Self {
Self {
supported: true,
unsupported_reason: None,
}
}
fn unsupported(reason: impl Into<String>) -> Self {
Self {
supported: false,
unsupported_reason: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AvailableUpdate {
pub version: String,
pub release_notes: Option<String>,
pub release_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Operation {
Check,
Install,
}
#[derive(Debug)]
struct Machine {
snapshot: UpdateSnapshot,
operation: Option<Operation>,
last_automatic_check: Option<Duration>,
}
pub struct UpdaterState {
machine: Mutex<Machine>,
#[cfg(feature = "desktop-runtime")]
pending: Mutex<Option<Update>>,
}
impl UpdaterState {
pub fn new(current_version: impl Into<String>) -> Self {
Self::with_install_support(current_version, native_install_support())
}
fn with_install_support(
current_version: impl Into<String>,
install_support: InstallSupport,
) -> Self {
Self {
machine: Mutex::new(Machine {
snapshot: UpdateSnapshot {
phase: UpdatePhase::Idle,
current_version: current_version.into(),
latest_version: None,
release_notes: None,
release_url: None,
checked_at_epoch_ms: None,
downloaded_bytes: None,
total_bytes: None,
install_supported: install_support.supported,
install_unsupported_reason: install_support.unsupported_reason,
error: None,
},
operation: None,
last_automatic_check: None,
}),
#[cfg(feature = "desktop-runtime")]
pending: Mutex::new(None),
}
}
pub fn should_run_automatic_check(&self, elapsed: Duration) -> bool {
let mut machine = self.machine.lock().expect("updater state mutex poisoned");
let due = machine
.last_automatic_check
.map(|last| elapsed.saturating_sub(last) >= AUTOMATIC_CHECK_INTERVAL)
.unwrap_or(true);
if due {
machine.last_automatic_check = Some(elapsed);
}
due
}
pub fn begin_check(&self) -> Result<(), String> {
let mut machine = self.machine.lock().expect("updater state mutex poisoned");
ensure_idle(&machine)?;
machine.operation = Some(Operation::Check);
machine.snapshot.phase = UpdatePhase::Checking;
machine.snapshot.error = None;
machine.snapshot.downloaded_bytes = None;
machine.snapshot.total_bytes = None;
Ok(())
}
pub fn begin_install(&self) -> Result<(), String> {
let mut machine = self.machine.lock().expect("updater state mutex poisoned");
ensure_idle(&machine)?;
if machine.snapshot.phase != UpdatePhase::Available {
return Err("No desktop update is ready to install.".to_string());
}
if !machine.snapshot.install_supported {
return Err(machine
.snapshot
.install_unsupported_reason
.clone()
.unwrap_or_else(|| "Automatic update installation is unavailable.".to_string()));
}
machine.operation = Some(Operation::Install);
machine.snapshot.phase = UpdatePhase::Downloading;
machine.snapshot.error = None;
machine.snapshot.downloaded_bytes = Some(0);
machine.snapshot.total_bytes = None;
Ok(())
}
pub fn snapshot(&self) -> UpdateSnapshot {
self.machine
.lock()
.expect("updater state mutex poisoned")
.snapshot
.clone()
}
pub fn complete_check(&self, update: Option<AvailableUpdate>, checked_at_epoch_ms: u64) {
let mut machine = self.machine.lock().expect("updater state mutex poisoned");
machine.operation = None;
machine.snapshot.checked_at_epoch_ms = Some(checked_at_epoch_ms);
machine.snapshot.downloaded_bytes = None;
machine.snapshot.total_bytes = None;
machine.snapshot.error = None;
match update {
Some(update) => {
machine.snapshot.phase = UpdatePhase::Available;
machine.snapshot.latest_version = Some(update.version);
machine.snapshot.release_notes = update.release_notes;
machine.snapshot.release_url = update.release_url;
}
None => {
machine.snapshot.phase = UpdatePhase::UpToDate;
machine.snapshot.latest_version = None;
machine.snapshot.release_notes = None;
machine.snapshot.release_url = None;
}
}
}
pub fn update_download_progress(&self, chunk_bytes: usize, total_bytes: Option<u64>) {
let mut machine = self.machine.lock().expect("updater state mutex poisoned");
let downloaded = machine.snapshot.downloaded_bytes.unwrap_or(0);
machine.snapshot.downloaded_bytes = Some(downloaded.saturating_add(chunk_bytes as u64));
if total_bytes.is_some() {
machine.snapshot.total_bytes = total_bytes;
}
}
pub fn mark_installing(&self) {
self.machine
.lock()
.expect("updater state mutex poisoned")
.snapshot
.phase = UpdatePhase::Installing;
}
pub fn fail_operation(&self, message: impl Into<String>) {
let mut machine = self.machine.lock().expect("updater state mutex poisoned");
let operation = machine.operation.take();
machine.snapshot.phase = if machine.snapshot.latest_version.is_some()
&& matches!(operation, Some(Operation::Check | Operation::Install))
{
UpdatePhase::Available
} else {
UpdatePhase::Error
};
machine.snapshot.error = Some(message.into());
}
}
fn native_install_support() -> InstallSupport {
let appimage = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from);
let appdir = std::env::var_os("APPDIR").map(std::path::PathBuf::from);
let current_exe = std::env::current_exe().ok();
let temp_dir = std::env::temp_dir();
let environment = InstallEnvironment {
appimage: appimage.as_deref(),
appdir: appdir.as_deref(),
current_exe: current_exe.as_deref(),
temp_dir: &temp_dir,
appimage_is_file: appimage.as_ref().is_some_and(|path| path.is_file()),
};
install_support_for(current_platform(), environment)
}
fn current_platform() -> UpdatePlatform {
match std::env::consts::OS {
"linux" => UpdatePlatform::Linux,
"macos" => UpdatePlatform::MacOs,
"windows" => UpdatePlatform::Windows,
_ => UpdatePlatform::Other,
}
}
fn install_support_for(
platform: UpdatePlatform,
environment: InstallEnvironment<'_>,
) -> InstallSupport {
match platform {
UpdatePlatform::MacOs | UpdatePlatform::Windows => InstallSupport::supported(),
UpdatePlatform::Linux if is_validated_appimage(&environment) => InstallSupport::supported(),
UpdatePlatform::Linux => InstallSupport::unsupported(LINUX_INSTALL_UNSUPPORTED),
UpdatePlatform::Other => InstallSupport::unsupported(
"Automatic update installation is not supported on this operating system.",
),
}
}
fn is_validated_appimage(environment: &InstallEnvironment<'_>) -> bool {
let (Some(appimage), Some(appdir), Some(current_exe)) = (
environment.appimage,
environment.appdir,
environment.current_exe,
) else {
return false;
};
appimage.is_absolute()
&& environment.appimage_is_file
&& appdir.is_absolute()
&& appdir.starts_with(environment.temp_dir)
&& appdir
.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with(".mount_"))
&& current_exe.starts_with(appdir)
}
#[cfg(feature = "desktop-runtime")]
impl UpdaterState {
fn set_pending(&self, update: Option<Update>) {
*self.pending.lock().expect("pending update mutex poisoned") = update;
}
fn pending(&self) -> Option<Update> {
self.pending
.lock()
.expect("pending update mutex poisoned")
.clone()
}
}
fn ensure_idle(machine: &Machine) -> Result<(), String> {
if machine.operation.is_some() {
Err("Another update operation is already in progress.".to_string())
} else {
Ok(())
}
}
pub trait BackendShutdown {
fn stop_owned_backend(&self);
}
pub fn prepare_updater_restart(backend: &impl BackendShutdown) {
backend.stop_owned_backend();
}
#[cfg(feature = "desktop-runtime")]
impl BackendShutdown for BackendState {
fn stop_owned_backend(&self) {
self.stop();
}
}
#[cfg(feature = "desktop-runtime")]
#[tauri::command]
pub fn get_update_state(
state: State<'_, UpdaterState>,
backend: State<'_, BackendState>,
webview: WebviewWindow,
) -> Result<UpdateSnapshot, String> {
backend.require_owned_origin(&webview)?;
Ok(state.snapshot())
}
#[cfg(feature = "desktop-runtime")]
#[tauri::command]
pub async fn check_for_updates(
app: AppHandle,
state: State<'_, UpdaterState>,
backend: State<'_, BackendState>,
webview: WebviewWindow,
) -> Result<UpdateSnapshot, String> {
backend.require_owned_origin(&webview)?;
run_check(&app, &state).await
}
#[cfg(feature = "desktop-runtime")]
#[tauri::command]
pub async fn install_update(
app: AppHandle,
state: State<'_, UpdaterState>,
backend: State<'_, BackendState>,
webview: WebviewWindow,
) -> Result<UpdateSnapshot, String> {
backend.require_owned_origin(&webview)?;
let update = state
.pending()
.ok_or_else(|| "No desktop update is ready to install.".to_string())?;
state.begin_install()?;
let download_result = update
.download_and_install(
|chunk_bytes, total_bytes| {
state.update_download_progress(chunk_bytes, total_bytes);
},
|| state.mark_installing(),
)
.await;
if let Err(err) = download_result {
let message = format!("Desktop update installation failed: {err}");
state.fail_operation(&message);
return Err(message);
}
let backend = app.state::<BackendState>().inner().clone();
prepare_updater_restart(&backend);
app.restart();
}
#[cfg(feature = "desktop-runtime")]
pub fn start_automatic_checks(app: AppHandle) {
std::thread::spawn(move || {
let started = Instant::now();
loop {
let state = app.state::<UpdaterState>();
if state.should_run_automatic_check(started.elapsed()) {
let _ = tauri::async_runtime::block_on(run_check(&app, &state));
}
std::thread::sleep(AUTOMATIC_CHECK_INTERVAL);
}
});
}
#[cfg(feature = "desktop-runtime")]
async fn run_check(app: &AppHandle, state: &UpdaterState) -> Result<UpdateSnapshot, String> {
state.begin_check()?;
let backend = app.state::<BackendState>().inner().clone();
let updater = match app
.updater_builder()
.on_before_exit(move || backend.stop())
.build()
{
Ok(updater) => updater,
Err(err) => return fail_check(state, err),
};
match updater.check().await {
Ok(update) => {
let metadata = update.as_ref().map(|update| AvailableUpdate {
version: update.version.clone(),
release_notes: update.body.clone(),
release_url: Some(release_url(&update.version)),
});
state.set_pending(update);
state.complete_check(metadata, now_epoch_ms());
Ok(state.snapshot())
}
Err(err) => fail_check(state, err),
}
}
#[cfg(feature = "desktop-runtime")]
fn fail_check(
state: &UpdaterState,
error: impl std::fmt::Display,
) -> Result<UpdateSnapshot, String> {
let message = format!("Desktop update check failed: {error}");
state.fail_operation(&message);
Err(message)
}
#[cfg(feature = "desktop-runtime")]
fn release_url(version: &str) -> String {
let tag = if version.starts_with('v') {
version.to_string()
} else {
format!("v{version}")
};
format!("https://github.com/kdlbs/kandev/releases/tag/{tag}")
}
#[cfg(feature = "desktop-runtime")]
fn now_epoch_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn install_environment<'a>(
appimage: Option<&'a Path>,
appdir: Option<&'a Path>,
current_exe: Option<&'a Path>,
appimage_is_file: bool,
) -> InstallEnvironment<'a> {
InstallEnvironment {
appimage,
appdir,
current_exe,
temp_dir: Path::new("/tmp"),
appimage_is_file,
}
}
fn supported_updater_state() -> UpdaterState {
UpdaterState::with_install_support("1.0.0", InstallSupport::supported())
}
#[test]
fn automatic_checks_run_immediately_then_no_more_than_once_per_day() {
let state = UpdaterState::new("1.0.0");
assert!(state.should_run_automatic_check(Duration::ZERO));
assert!(
!state.should_run_automatic_check(AUTOMATIC_CHECK_INTERVAL - Duration::from_secs(1))
);
assert!(state.should_run_automatic_check(AUTOMATIC_CHECK_INTERVAL));
}
#[test]
fn forged_appimage_environment_is_not_installable() {
let support = install_support_for(
UpdatePlatform::Linux,
install_environment(
Some(Path::new("/opt/Kandev.AppImage")),
Some(Path::new("/tmp/.mount_kandev")),
Some(Path::new("/usr/bin/kandev-desktop")),
false,
),
);
assert!(!support.supported);
}
#[test]
fn validated_appimage_and_native_desktop_platforms_are_installable() {
let valid_appimage = install_environment(
Some(Path::new("/opt/Kandev.AppImage")),
Some(Path::new("/tmp/.mount_kandev")),
Some(Path::new("/tmp/.mount_kandev/usr/bin/kandev-desktop")),
true,
);
assert!(install_support_for(UpdatePlatform::Linux, valid_appimage).supported);
assert!(
install_support_for(
UpdatePlatform::MacOs,
install_environment(None, None, None, false)
)
.supported
);
assert!(
install_support_for(
UpdatePlatform::Windows,
install_environment(None, None, None, false)
)
.supported
);
}
#[test]
fn linux_package_install_exposes_manual_update_guidance() {
let support = install_support_for(
UpdatePlatform::Linux,
install_environment(None, None, None, false),
);
assert!(!support.supported);
assert!(support
.unsupported_reason
.as_deref()
.unwrap_or_default()
.contains("package manager"));
}
#[test]
fn only_one_update_operation_can_be_in_flight() {
let state = UpdaterState::new("1.0.0");
state.begin_check().expect("first check should start");
assert_eq!(
state.begin_check(),
Err("Another update operation is already in progress.".to_string())
);
assert_eq!(
state.begin_install(),
Err("Another update operation is already in progress.".to_string())
);
}
#[test]
fn install_requires_a_confirmed_available_update() {
let state = UpdaterState::new("1.0.0");
assert_eq!(
state.begin_install(),
Err("No desktop update is ready to install.".to_string())
);
assert_eq!(state.snapshot().phase, UpdatePhase::Idle);
}
#[cfg(target_os = "linux")]
#[test]
fn install_is_blocked_outside_a_validated_appimage_runtime() {
let state = UpdaterState::new("1.0.0");
state.begin_check().unwrap();
state.complete_check(
Some(AvailableUpdate {
version: "1.1.0".to_string(),
release_notes: None,
release_url: None,
}),
42,
);
assert_eq!(
state.begin_install(),
Err(LINUX_INSTALL_UNSUPPORTED.to_string())
);
assert_eq!(state.snapshot().phase, UpdatePhase::Available);
}
#[test]
fn updater_restart_stops_the_owned_backend_once() {
struct FakeBackend(AtomicUsize);
impl BackendShutdown for FakeBackend {
fn stop_owned_backend(&self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
let backend = FakeBackend(AtomicUsize::new(0));
prepare_updater_restart(&backend);
assert_eq!(backend.0.load(Ordering::SeqCst), 1);
}
#[test]
fn successful_checks_publish_available_and_no_update_states() {
let state = supported_updater_state();
state.begin_check().unwrap();
state.complete_check(
Some(AvailableUpdate {
version: "1.1.0".to_string(),
release_notes: Some("Changes".to_string()),
release_url: Some("https://example.test/v1.1.0".to_string()),
}),
42,
);
assert_eq!(
state.snapshot(),
UpdateSnapshot {
phase: UpdatePhase::Available,
current_version: "1.0.0".to_string(),
latest_version: Some("1.1.0".to_string()),
release_notes: Some("Changes".to_string()),
release_url: Some("https://example.test/v1.1.0".to_string()),
checked_at_epoch_ms: Some(42),
downloaded_bytes: None,
total_bytes: None,
install_supported: true,
install_unsupported_reason: None,
error: None,
}
);
state.begin_check().unwrap();
state.complete_check(None, 84);
let snapshot = state.snapshot();
assert_eq!(snapshot.phase, UpdatePhase::UpToDate);
assert_eq!(snapshot.checked_at_epoch_ms, Some(84));
assert_eq!(snapshot.latest_version, None);
}
#[test]
fn download_progress_and_failure_preserve_a_retryable_update() {
let state = supported_updater_state();
state.begin_check().unwrap();
state.complete_check(
Some(AvailableUpdate {
version: "1.1.0".to_string(),
release_notes: None,
release_url: None,
}),
42,
);
state.begin_install().unwrap();
state.update_download_progress(25, Some(100));
assert_eq!(state.snapshot().downloaded_bytes, Some(25));
assert_eq!(state.snapshot().total_bytes, Some(100));
state.mark_installing();
assert_eq!(state.snapshot().phase, UpdatePhase::Installing);
state.fail_operation("Signature verification failed");
let snapshot = state.snapshot();
assert_eq!(snapshot.phase, UpdatePhase::Available);
assert_eq!(snapshot.latest_version.as_deref(), Some("1.1.0"));
assert_eq!(
snapshot.error.as_deref(),
Some("Signature verification failed")
);
state
.begin_install()
.expect("failed installs remain retryable");
}
}
+334
View File
@@ -0,0 +1,334 @@
#[cfg(feature = "desktop-runtime")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "desktop-runtime")]
use std::{
fs,
path::PathBuf,
sync::{
mpsc::{self, Receiver, RecvTimeoutError, Sender},
Arc, Mutex,
},
thread,
time::Duration,
};
#[cfg(feature = "desktop-runtime")]
use tauri::{PhysicalPosition, PhysicalSize, WebviewWindow};
const MIN_VISIBLE_WIDTH: i32 = 120;
const MIN_VISIBLE_HEIGHT: i32 = 80;
#[cfg(feature = "desktop-runtime")]
const SAVE_DEBOUNCE: Duration = Duration::from_millis(350);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "desktop-runtime", derive(Serialize, Deserialize))]
pub struct WindowBounds {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub maximized: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MonitorBounds {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
}
pub fn visible_restore(saved: WindowBounds, monitors: &[MonitorBounds]) -> WindowBounds {
if monitors.is_empty() || monitors.iter().any(|monitor| intersects(saved, *monitor)) {
return saved;
}
let monitor = monitors[0];
let width = saved.width.max(MIN_VISIBLE_WIDTH as u32).min(monitor.width);
let height = saved
.height
.max(MIN_VISIBLE_HEIGHT as u32)
.min(monitor.height);
WindowBounds {
x: monitor.x,
y: monitor.y,
width,
height,
maximized: saved.maximized,
}
}
fn intersects(window: WindowBounds, monitor: MonitorBounds) -> bool {
let window_right = window.x.saturating_add(window.width as i32);
let window_bottom = window.y.saturating_add(window.height as i32);
let monitor_right = monitor.x.saturating_add(monitor.width as i32);
let monitor_bottom = monitor.y.saturating_add(monitor.height as i32);
window_right >= monitor.x.saturating_add(MIN_VISIBLE_WIDTH)
&& window.x <= monitor_right.saturating_sub(MIN_VISIBLE_WIDTH)
&& window_bottom >= monitor.y.saturating_add(MIN_VISIBLE_HEIGHT)
&& window.y <= monitor_bottom.saturating_sub(MIN_VISIBLE_HEIGHT)
}
#[derive(Debug, Default)]
struct SaveSchedule {
revision: u64,
}
impl SaveSchedule {
fn next(&mut self) -> u64 {
self.revision = self.revision.wrapping_add(1);
self.revision
}
fn is_current(&self, revision: u64) -> bool {
self.revision == revision
}
}
#[cfg(feature = "desktop-runtime")]
pub struct WindowStateStore {
path: PathBuf,
last_normal: Mutex<Option<WindowBounds>>,
pending: Sender<(u64, WindowBounds)>,
schedule: Arc<Mutex<SaveSchedule>>,
write_lock: Arc<Mutex<()>>,
}
#[cfg(feature = "desktop-runtime")]
impl WindowStateStore {
pub fn new(path: PathBuf) -> Self {
let (pending, receiver) = mpsc::channel();
let schedule = Arc::new(Mutex::new(SaveSchedule::default()));
let write_lock = Arc::new(Mutex::new(()));
start_save_worker(path.clone(), receiver, schedule.clone(), write_lock.clone());
Self {
path,
last_normal: Mutex::new(None),
pending,
schedule,
write_lock,
}
}
pub fn restore(&self, window: &WebviewWindow) -> Result<(), String> {
let saved = match fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice::<WindowBounds>(&bytes)
.map_err(|err| format!("invalid saved desktop window state: {err}"))?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(format!("could not read desktop window state: {err}")),
};
let monitors = window
.available_monitors()
.map_err(|err| err.to_string())?
.into_iter()
.map(|monitor| {
let work_area = monitor.work_area();
MonitorBounds {
x: work_area.position.x,
y: work_area.position.y,
width: work_area.size.width,
height: work_area.size.height,
}
})
.collect::<Vec<_>>();
let restored = visible_restore(saved, &monitors);
window
.set_size(PhysicalSize::new(restored.width, restored.height))
.map_err(|err| err.to_string())?;
window
.set_position(PhysicalPosition::new(restored.x, restored.y))
.map_err(|err| err.to_string())?;
*self
.last_normal
.lock()
.expect("window state mutex poisoned") = Some(WindowBounds {
maximized: false,
..restored
});
if restored.maximized {
window.maximize().map_err(|err| err.to_string())?;
}
Ok(())
}
pub fn schedule_save(&self, window: &WebviewWindow) -> Result<(), String> {
let Some(bounds) = self.capture(window)? else {
return Ok(());
};
let revision = self
.schedule
.lock()
.expect("window save schedule mutex poisoned")
.next();
self.pending
.send((revision, bounds))
.map_err(|_| "desktop window state writer stopped unexpectedly".to_string())
}
pub fn save(&self, window: &WebviewWindow) -> Result<(), String> {
let Some(bounds) = self.capture(window)? else {
return Ok(());
};
self.schedule
.lock()
.expect("window save schedule mutex poisoned")
.next();
let _guard = self
.write_lock
.lock()
.expect("window state writer mutex poisoned");
write_bounds(&self.path, bounds)
}
fn capture(&self, window: &WebviewWindow) -> Result<Option<WindowBounds>, String> {
if window.is_minimized().unwrap_or(false) {
return Ok(None);
}
if window.is_fullscreen().unwrap_or(false) {
return Ok(None);
}
let maximized = window.is_maximized().unwrap_or(false);
let bounds = if maximized {
self.last_normal
.lock()
.expect("window state mutex poisoned")
.map(|bounds| WindowBounds {
maximized: true,
..bounds
})
} else {
let position = window.outer_position().map_err(|err| err.to_string())?;
let size = window.outer_size().map_err(|err| err.to_string())?;
let bounds = WindowBounds {
x: position.x,
y: position.y,
width: size.width,
height: size.height,
maximized: false,
};
*self
.last_normal
.lock()
.expect("window state mutex poisoned") = Some(bounds);
Some(bounds)
};
Ok(bounds)
}
}
#[cfg(feature = "desktop-runtime")]
fn start_save_worker(
path: PathBuf,
receiver: Receiver<(u64, WindowBounds)>,
schedule: Arc<Mutex<SaveSchedule>>,
write_lock: Arc<Mutex<()>>,
) {
thread::spawn(move || {
while let Ok(mut pending) = receiver.recv() {
loop {
match receiver.recv_timeout(SAVE_DEBOUNCE) {
Ok(next) => pending = next,
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => return,
}
}
let _guard = write_lock
.lock()
.expect("window state writer mutex poisoned");
if schedule
.lock()
.expect("window save schedule mutex poisoned")
.is_current(pending.0)
{
if let Err(err) = write_bounds(&path, pending.1) {
eprintln!("Could not persist desktop window state: {err}");
}
}
}
});
}
#[cfg(feature = "desktop-runtime")]
fn write_bounds(path: &PathBuf, bounds: WindowBounds) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("could not create desktop state directory: {err}"))?;
}
let bytes = serde_json::to_vec(&bounds)
.map_err(|err| format!("could not serialize desktop window state: {err}"))?;
fs::write(path, bytes).map_err(|err| format!("could not save desktop window state: {err}"))
}
#[cfg(test)]
mod tests {
use super::*;
const PRIMARY: MonitorBounds = MonitorBounds {
x: 0,
y: 0,
width: 1920,
height: 1080,
};
#[test]
fn visible_saved_bounds_are_preserved() {
let saved = WindowBounds {
x: 100,
y: 80,
width: 1280,
height: 900,
maximized: true,
};
assert_eq!(visible_restore(saved, &[PRIMARY]), saved);
}
#[test]
fn disconnected_monitor_bounds_restore_on_the_primary_monitor() {
let saved = WindowBounds {
x: 3000,
y: 200,
width: 1280,
height: 900,
maximized: false,
};
assert_eq!(
visible_restore(saved, &[PRIMARY]),
WindowBounds {
x: 0,
y: 0,
width: 1280,
height: 900,
maximized: false,
}
);
}
#[test]
fn oversized_bounds_are_clamped_to_the_restore_monitor() {
let saved = WindowBounds {
x: -5000,
y: -5000,
width: 4000,
height: 3000,
maximized: false,
};
let restored = visible_restore(saved, &[PRIMARY]);
assert_eq!(restored.width, PRIMARY.width);
assert_eq!(restored.height, PRIMARY.height);
assert_eq!((restored.x, restored.y), (PRIMARY.x, PRIMARY.y));
}
#[test]
fn newer_save_revisions_supersede_pending_work() {
let mut schedule = SaveSchedule::default();
let first = schedule.next();
let second = schedule.next();
assert!(!schedule.is_current(first));
assert!(schedule.is_current(second));
schedule.next();
assert!(!schedule.is_current(second));
}
}
+10
View File
@@ -19,6 +19,7 @@
"minWidth": 960,
"minHeight": 640,
"resizable": true,
"visible": false,
"fullscreen": false
}
],
@@ -29,9 +30,18 @@
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; frame-ancestors 'none'"
}
},
"plugins": {
"updater": {
"endpoints": [
"https://github.com/kdlbs/kandev/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI0NzVGQ0MwODhDODM0OEMKUldTTU5NaUl3UHgxdENoYmIvRDR1Y3F1Tys2ZXN5MFNRbHRHSHpmeWhIdzIxVFJEVUJubDAwWmwK"
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": false,
"publisher": "Kandev",
"homepage": "https://github.com/kdlbs/kandev",
"resources": {