From 039ac6f9d3c8300ae7abdf66ba640bf4c5ac0baf Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 13 Aug 2026 01:02:33 +0000 Subject: [PATCH] feat: add PBS datastore management and harden macOS keychain storage Add Proxmox Backup Server datastore management: overview, datastore detail, download/prune/verify/GC dialogs, usePbs hook, backend commands, and tests. Fix macOS keychain re-writes failing with 'item already exists': replace keyring 3 with keyring-core plus per-platform stores (macOS Keychain, Windows Credential Manager, Linux keyutils), recover by deleting the stale item and retrying once, and surface actionable messages for locked keychains. --- README.md | 21 +- package-lock.json | 10 + package.json | 1 + src-tauri/Cargo.lock | 168 +++- src-tauri/Cargo.toml | 14 +- src-tauri/capabilities/default.json | 3 +- src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/gen/schemas/desktop-schema.json | 66 ++ src-tauri/gen/schemas/linux-schema.json | 66 ++ src-tauri/src/connection.rs | 611 +++++++++----- src-tauri/src/console_proxy.rs | 6 +- src-tauri/src/error.rs | 3 + src-tauri/src/keyring.rs | 219 +++++ src-tauri/src/lib.rs | 280 ++++++- src-tauri/src/pbs.rs | 756 ++++++++++++++++++ src-tauri/src/websocket.rs | 24 +- src-tauri/tests/api_client.rs | 4 +- src-tauri/tests/api_commands.rs | 1 + src-tauri/tests/api_console.rs | 1 + src-tauri/tests/api_disk_network.rs | 1 + src-tauri/tests/api_pbs.rs | 586 ++++++++++++++ src-tauri/tests/api_snapshots_backups.rs | 1 + src-tauri/tests/cluster_flow.rs | 1 + src-tauri/tests/discovery.rs | 1 + src-tauri/tests/failover.rs | 3 + src-tauri/tests/live.rs | 6 +- src-tauri/tests/persistence.rs | 3 + src/App.tsx | 45 ++ src/components/command/CommandPalette.tsx | 292 ++++--- .../connections/ConnectionDialog.tsx | 67 +- src/components/layout/Sidebar.tsx | 78 +- src/components/pbs/PbsDatastoreDetail.tsx | 567 +++++++++++++ src/components/pbs/PbsDatastores.tsx | 166 ++++ src/components/pbs/PbsOverview.tsx | 212 +++++ .../pbs/dialogs/DownloadFilesDialog.tsx | 147 ++++ src/components/pbs/dialogs/GcDialog.tsx | 47 ++ src/components/pbs/dialogs/PruneDialog.tsx | 113 +++ src/components/pbs/dialogs/VerifyDialog.tsx | 48 ++ src/hooks/usePbs.ts | 328 ++++++++ src/lib/tauri.ts | 291 ++++++- src/types/connection.ts | 6 + src/types/pbs.ts | 97 +++ 43 files changed, 4934 insertions(+), 430 deletions(-) create mode 100644 src-tauri/src/keyring.rs create mode 100644 src-tauri/src/pbs.rs create mode 100644 src-tauri/tests/api_pbs.rs create mode 100644 src/components/pbs/PbsDatastoreDetail.tsx create mode 100644 src/components/pbs/PbsDatastores.tsx create mode 100644 src/components/pbs/PbsOverview.tsx create mode 100644 src/components/pbs/dialogs/DownloadFilesDialog.tsx create mode 100644 src/components/pbs/dialogs/GcDialog.tsx create mode 100644 src/components/pbs/dialogs/PruneDialog.tsx create mode 100644 src/components/pbs/dialogs/VerifyDialog.tsx create mode 100644 src/hooks/usePbs.ts create mode 100644 src/types/pbs.ts diff --git a/README.md b/README.md index 33a5e07..a57bf1d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Clustri -A cross-platform desktop client for managing Proxmox VE servers and clusters. The backend is Rust on Tauri 2; the UI is React 19 with TypeScript, Vite, and Tailwind CSS v4. +A cross-platform desktop client for managing Proxmox VE servers and clusters, and Proxmox Backup Server. The backend is Rust on Tauri 2; the UI is React 19 with TypeScript, Vite, and Tailwind CSS v4. ## Features @@ -45,6 +45,15 @@ A cross-platform desktop client for managing Proxmox VE servers and clusters. Th - Snapshot list, create, delete, rollback. - Backup jobs: list, create, edit, delete, run now. Restore or delete existing backups. +### Backup server (PBS) + +- Connect to a Proxmox Backup Server (port 8007) as its own connection type. +- Datastore overview with usage, and a backup groups → snapshot drill-down. +- Download individual snapshot archives (raw or decoded) via the OS save dialog; delete snapshots and backup groups. +- One-shot verify, prune (retention rules plus dry-run), and garbage collection. +- Read-only lists of scheduled verify, prune, and garbage-collection jobs, plus the task list. +- Whole-VM/CT restore is performed through Proxmox VE, not PBS. + ### Console - noVNC for VMs and an xterm.js terminal for containers, with fullscreen and Ctrl+Alt+Del support. @@ -103,6 +112,10 @@ The same core routes each request through an ordered, deduplicated endpoint list Tauri commands cover: connection add/remove/update/load, connect/disconnect, set active; password and token login, logout, stored credentials; certificate info and trust; nodes, VMs, storage, storage content and detail, tasks, cluster status; QEMU and LXC lifecycle and migration; disks, NICs, snapshots; VNC and terminal proxies; websocket URL; backup jobs and restore; and the tray menu. +### Backup server (PBS) + +PBS connections reuse the same ConnectionManager/request core with server-type-aware auth headers (`PBSAPIToken`/`PBSAuthCookie` vs `PVEAPIToken`/`PVEAuthCookie`). PBS is single-host — there is no node discovery or cluster merge. The PBS module lives in `src-tauri/src/pbs.rs`. Tasks flow through the shared task list via `GET /nodes/localhost/tasks`, and snapshot archives download through the raw `/download` and `/download-decoded` endpoints. + ### TLS Proxmox nodes typically run self-signed certificates, so the transport accepts them and the app enforces trust itself. On first connect the certificate is captured in `src-tauri/src/tls.rs`, its SHA-256 fingerprint is shown for confirmation, and the fingerprint is pinned and checked on every later connect. @@ -239,6 +252,12 @@ Cluster discovery, failover, and same-cluster merging are covered by Rust tests 5. Uncheck Privilege Separation for full access. 6. Copy the token. It looks like `user@realm!tokenid=secret`. +### Proxmox Backup Server API token + +1. In the PBS web UI, go to Server Administrator → Access Control → API Tokens and create a token. +2. It uses the same `user@realm!tokenid=secret` format as the Proxmox VE token. +3. Connect to the PBS server as `https://host:8007`. + ### Connection settings When adding a connection you need: diff --git a/package-lock.json b/package-lock.json index 69d7fb5..8f86680 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@radix-ui/react-tooltip": "^1.2.16", "@tanstack/react-query": "^5.101.4", "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.27.0", @@ -2941,6 +2942,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/package.json b/package.json index 99487f6..c18f7b2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-tooltip": "^1.2.16", "@tanstack/react-query": "^5.101.4", "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.27.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8cbc05f..58687d2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -415,20 +426,25 @@ dependencies = [ name = "clustri" version = "0.1.0" dependencies = [ + "apple-native-keyring-store", "futures-util", "hex", + "http", "httpmock", - "keyring", + "keyring-core", + "linux-keyutils-keyring-store", "percent-encoding", "rcgen", "reqwest 0.12.28", "rustls", "rustls-pemfile", + "security-framework", "serde", "serde_json", "sha2", "tauri", "tauri-build", + "tauri-plugin-dialog", "tempfile", "thiserror 2.0.19", "tokio", @@ -436,6 +452,7 @@ dependencies = [ "tokio-tungstenite", "url", "uuid", + "windows-native-keyring-store", "x509-cert", ] @@ -474,16 +491,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -507,7 +514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.1", - "core-foundation 0.10.1", + "core-foundation", "core-graphics-types", "foreign-types", "libc", @@ -520,7 +527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.1", - "core-foundation 0.10.1", + "core-foundation", "libc", ] @@ -1936,18 +1943,12 @@ dependencies = [ ] [[package]] -name = "keyring" -version = "3.6.3" +name = "keyring-core" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" dependencies = [ - "byteorder", - "linux-keyutils", "log", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", - "zeroize", ] [[package]] @@ -2018,6 +2019,16 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-keyutils-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39fbed79f71dc21eb21d3d07c0e908a3c58ff9a1fdbf5cf44230fb3deb6d994b" +dependencies = [ + "keyring-core", + "linux-keyutils", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2315,6 +2326,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2985,6 +2997,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3152,19 +3188,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -3172,7 +3195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.1", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -3674,7 +3697,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2", - "core-foundation 0.10.1", + "core-foundation", "core-graphics", "crossbeam-channel", "dbus", @@ -3836,6 +3859,64 @@ 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-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4916,6 +4997,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0e332d9..75ededa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,12 +10,13 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } tokio = { version = "1", features = ["full"] } thiserror = "2" -keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } +keyring-core = "1" uuid = { version = "1", features = ["v4"] } rustls = "0.23" rustls-pemfile = "2" @@ -24,6 +25,7 @@ x509-cert = "0.2" sha2 = "0.10" hex = "0.4" tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +http = "1" futures-util = "0.3" url = "2" percent-encoding = "2" @@ -33,6 +35,16 @@ httpmock = "0.8" rcgen = "0.13" tempfile = "3" +[target.'cfg(target_os = "macos")'.dependencies] +apple-native-keyring-store = { version = "1", features = ["keychain"] } +security-framework = "3" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-native-keyring-store = "1" + +[target.'cfg(target_os = "linux")'.dependencies] +linux-keyutils-keyring-store = "1" + [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 65b1c54..b1c99b8 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,7 @@ "core:window:allow-hide", "core:window:allow-close", "core:window:allow-set-focus", - "core:window:allow-is-visible" + "core:window:allow-is-visible", + "dialog:default" ] } diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 0eebfc4..f2f210a 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 8351bae..391a0ab 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Clustri","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","core:event:allow-emit-to","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-is-visible"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Clustri","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","core:event:allow-emit-to","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-is-visible","dialog:default"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index 3286645..24c9001 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -2191,6 +2191,72 @@ "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." } ] }, diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json index 3286645..24c9001 100644 --- a/src-tauri/gen/schemas/linux-schema.json +++ b/src-tauri/gen/schemas/linux-schema.json @@ -2191,6 +2191,72 @@ "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." } ] }, diff --git a/src-tauri/src/connection.rs b/src-tauri/src/connection.rs index 58f9624..4599c96 100644 --- a/src-tauri/src/connection.rs +++ b/src-tauri/src/connection.rs @@ -1,4 +1,8 @@ use crate::error::Error; +use crate::keyring::{ + delete_credential as keyring_delete_credential, describe_error as keyring_describe_error, + entry as keyring_entry, keyring, set_password as keyring_set_password, +}; use crate::proxmox::{ AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterNode, ClusterStatus, CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot, @@ -75,16 +79,57 @@ pub enum AuthMode { Password, } +/// The kind of Proxmox server a connection targets. The two platforms share +/// the JSON API shape and the ticket/token authentication model but use +/// different header names and login endpoints. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerType { + Pve, + Pbs, +} + +impl ServerType { + /// Maps a connection's `serverType` string to a [`ServerType`]. Any value + /// other than `"pbs"` is treated as PVE. + pub(crate) fn from_config(s: &str) -> ServerType { + if s == "pbs" { + ServerType::Pbs + } else { + ServerType::Pve + } + } + + /// The `Authorization` header value prefix for token authentication + /// (`PVEAPIToken` for VE, `PBSAPIToken` for PBS). + pub(crate) fn token_header_name(self) -> &'static str { + match self { + ServerType::Pve => "PVEAPIToken", + ServerType::Pbs => "PBSAPIToken", + } + } + + /// The `Cookie` header value prefix for ticket authentication + /// (`PVEAuthCookie` for VE, `PBSAuthCookie` for PBS). + pub(crate) fn cookie_header_name(self) -> &'static str { + match self { + ServerType::Pve => "PVEAuthCookie", + ServerType::Pbs => "PBSAuthCookie", + } + } +} + /// Authentication material for a Proxmox API request. /// -/// Token mode uses `token` as a `PVEAPIToken` header; password mode uses -/// `ticket` as a `PVEAuthCookie` (plus `csrf_token` for non-GET requests). +/// Token mode uses `token` as a `PVEAPIToken`/`PBSAPIToken` header; password +/// mode uses `ticket` as a `PVEAuthCookie`/`PBSAuthCookie` (plus `csrf_token` +/// for non-GET requests). The header names are chosen from `server_type`. #[derive(Debug, Clone)] pub struct AuthContext { pub mode: AuthMode, pub token: Option, pub ticket: Option, pub csrf_token: Option, + pub server_type: ServerType, } /// Builds the full Proxmox API URL for a request. @@ -101,7 +146,7 @@ fn build_api_url(base_url: &str, path: &str) -> crate::Result { /// Deserializes an API response payload into `T`, mapping a parse failure to a /// [`crate::Error::SerializationError`] that names the endpoint so the /// mismatch is easy to diagnose. -fn parse_api(endpoint: &str, data: serde_json::Value) -> crate::Result +pub(crate) fn parse_api(endpoint: &str, data: serde_json::Value) -> crate::Result where T: serde::de::DeserializeOwned, { @@ -160,29 +205,7 @@ pub async fn api_request( } let mut request = client.request(method, url); - match auth.mode { - AuthMode::Token => { - let token = auth - .token - .as_deref() - .ok_or_else(|| Error::InvalidCredentials("No API token configured".to_string()))?; - request = request.header("Authorization", format!("PVEAPIToken={}", token)); - } - AuthMode::Password => { - let ticket = auth - .ticket - .as_deref() - .ok_or_else(|| Error::AuthError("Not logged in: no session ticket".to_string()))?; - request = request.header("Cookie", format!("PVEAuthCookie={}", ticket)); - if needs_csrf { - let csrf = auth - .csrf_token - .as_deref() - .ok_or_else(|| Error::AuthError("Not logged in: no CSRF token".to_string()))?; - request = request.header("CSRFPreventionToken", csrf); - } - } - } + request = apply_auth_headers(request, auth, needs_csrf)?; if let Some(fields) = form { request = request.form(fields); @@ -202,13 +225,7 @@ pub async fn api_request( let status = response.status(); let text = response.text().await.map_err(Error::HttpError)?; - let body: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|_| { - if text.is_empty() { - serde_json::Value::Null - } else { - serde_json::Value::String(text) - } - }); + let body = parse_body_text(&text); if !status.is_success() { let message = error_message_from_body(&body, status.as_u16()); @@ -218,6 +235,63 @@ pub async fn api_request( Ok(body.get("data").cloned().unwrap_or(body)) } +/// Parses a raw response body into a JSON value, treating an empty body as +/// `Null` and a non-JSON body as a plain string. The standard +/// `{ "data": ... }` envelope is left intact; callers decide whether to +/// unwrap it. +fn parse_body_text(text: &str) -> serde_json::Value { + match serde_json::from_str(text) { + Ok(value) => value, + Err(_) if text.is_empty() => serde_json::Value::Null, + Err(_) => serde_json::Value::String(text.to_string()), + } +} + +/// Applies the authentication headers from `auth` to a request builder. +/// +/// Token mode sets an `Authorization` header whose value prefix depends on the +/// server type (`PVEAPIToken`/`PBSAPIToken`). Password mode sets a `Cookie` +/// header (`PVEAuthCookie`/`PBSAuthCookie`) and, for non-GET requests, the +/// `CSRFPreventionToken` header. Missing secrets surface as +/// [`Error::InvalidCredentials`]/[`Error::AuthError`], matching the behavior +/// of the pre-refactor inline injection in `api_request`. +fn apply_auth_headers( + mut request: reqwest::RequestBuilder, + auth: &AuthContext, + needs_csrf: bool, +) -> crate::Result { + match auth.mode { + AuthMode::Token => { + let token = auth + .token + .as_deref() + .ok_or_else(|| Error::InvalidCredentials("No API token configured".to_string()))?; + request = request.header( + "Authorization", + format!("{}={}", auth.server_type.token_header_name(), token), + ); + } + AuthMode::Password => { + let ticket = auth + .ticket + .as_deref() + .ok_or_else(|| Error::AuthError("Not logged in: no session ticket".to_string()))?; + request = request.header( + "Cookie", + format!("{}={}", auth.server_type.cookie_header_name(), ticket), + ); + if needs_csrf { + let csrf = auth + .csrf_token + .as_deref() + .ok_or_else(|| Error::AuthError("Not logged in: no CSRF token".to_string()))?; + request = request.header("CSRFPreventionToken", csrf); + } + } + } + Ok(request) +} + /// Builds the error message surfaced for a non-success API response. /// /// The `errors` field is preferred over the generic `message` (Proxmox @@ -267,9 +341,9 @@ fn error_message_from_body(body: &serde_json::Value, status: u16) -> String { format!("Proxmox API error (HTTP {})", status) } -struct Connection { - config: ConnectionConfig, - client: Client, +pub(crate) struct Connection { + pub(crate) config: ConnectionConfig, + pub(crate) client: Client, ticket: Mutex>, csrf_token: Mutex>, current_endpoint_index: Mutex, @@ -293,7 +367,8 @@ impl Connection { /// Token mode reads the token from the connection config, falling back to /// the keyring. Password mode uses the in-memory session, loading the /// ticket/CSRF token from the keyring and caching them if absent. - fn auth_context(&self) -> crate::Result { + pub(crate) fn auth_context(&self) -> crate::Result { + let server_type = ServerType::from_config(&self.config.server_type); if self.config.auth_mode == "token" { let token = match self.config.primary.token.as_deref() { Some(token) if !token.is_empty() => Some(token.to_string()), @@ -304,6 +379,7 @@ impl Connection { token, ticket: None, csrf_token: None, + server_type, }) } else { let ticket = self @@ -315,6 +391,7 @@ impl Connection { token: None, ticket: Some(ticket), csrf_token, + server_type, }) } } @@ -326,7 +403,7 @@ impl Connection { Ok(entry) => match entry.get_password() { Ok(value) => Ok(Some(value)), Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(Error::KeyringError(e.to_string())), + Err(e) => Err(Error::KeyringError(keyring_describe_error(&e))), }, Err(_) => Ok(None), } @@ -357,7 +434,7 @@ impl Connection { /// Returns the ordered, deduplicated candidate endpoint URLs for failover: /// the primary first, followed by each configured fallback. Empty URLs are /// dropped and duplicates are collapsed, preserving order. - fn endpoint_urls(&self) -> Vec { + pub(crate) fn endpoint_urls(&self) -> Vec { let mut urls: Vec = Vec::new(); let primary = self.config.primary.url.clone(); let fallbacks = self @@ -376,7 +453,7 @@ impl Connection { /// Remembers the endpoint that last served a request, so the next request /// resumes rotation there instead of re-testing a down primary. - fn set_endpoint_index(&self, idx: usize) { + pub(crate) fn set_endpoint_index(&self, idx: usize) { if let Ok(mut guard) = self.current_endpoint_index.lock() { *guard = idx; } @@ -384,7 +461,7 @@ impl Connection { /// Records the runtime status of the last request: `"connected"`, /// `"failover"`, or `"failed"`. - fn set_runtime_status(&self, status: &str) { + pub(crate) fn set_runtime_status(&self, status: &str) { if let Ok(mut guard) = self.runtime_status.lock() { *guard = status.to_string(); } @@ -406,7 +483,7 @@ impl Connection { /// (connection refused, timeouts, DNS resolution) trigger failover to the /// next candidate; authentication and API errors are returned immediately /// without rotating. - async fn request( + pub(crate) async fn request( &self, method: Method, path: &str, @@ -439,15 +516,95 @@ impl Connection { Err(last_transport_err .unwrap_or_else(|| Error::ConnectionFailed("no endpoints available".to_string()))) } -} -fn keyring_service() -> &'static str { - "clustri" -} + /// Streams a GET response body (binary, no `{data}` envelope unwrap) to + /// `dest`. + /// + /// Uses the same endpoint rotation and auth as `request()`: transport + /// failures rotate to the next candidate endpoint, while auth/API/other + /// errors propagate immediately. Returns the number of bytes written. + /// + /// Used by the PBS backup-download endpoints, which stream raw archive + /// bytes rather than a JSON envelope. + pub(crate) async fn download_to_file( + &self, + path: &str, + query: &[(&str, String)], + dest: &std::path::Path, + ) -> crate::Result { + let auth = self.auth_context()?; + let candidates = self.endpoint_urls(); + let start = *self + .current_endpoint_index + .lock() + .unwrap_or_else(|e| e.into_inner()); + let mut last_transport_err = None; + for offset in 0..candidates.len() { + let idx = (start + offset) % candidates.len(); + let url = &candidates[idx]; + match self + .download_from_endpoint(url, path, query, &auth, dest) + .await + { + Ok(bytes) => { + self.set_endpoint_index(idx); + self.set_runtime_status(if idx == 0 { "connected" } else { "failover" }); + return Ok(bytes); + } + Err(Error::ConnectionFailed(message)) => { + last_transport_err = Some(Error::ConnectionFailed(message)); + } + Err(error) => return Err(error), + } + } + self.set_runtime_status("failed"); + Err(last_transport_err + .unwrap_or_else(|| Error::ConnectionFailed("no endpoints available".to_string()))) + } -fn keyring_entry(connection_id: &str, field: &str) -> crate::Result { - let key = format!("{}:{}", connection_id, field); - keyring::Entry::new(keyring_service(), &key).map_err(|e| Error::KeyringError(e.to_string())) + /// Performs a single raw GET download against `base_url` and writes the + /// response body to `dest`. Auth headers mirror `api_request` (a GET needs + /// no CSRF header, but the cookie/authorization header is always set); a + /// non-success status is surfaced as [`Error::ApiError`]. + async fn download_from_endpoint( + &self, + base_url: &str, + path: &str, + query: &[(&str, String)], + auth: &AuthContext, + dest: &std::path::Path, + ) -> crate::Result { + let mut url = build_api_url(base_url, path)?; + { + let mut pairs = url.query_pairs_mut(); + for (key, value) in query { + pairs.append_pair(key, value); + } + } + + let request = apply_auth_headers(self.client.get(url), auth, false)?; + let response = request.send().await.map_err(|e| { + if e.is_connect() || e.is_timeout() || e.is_request() { + // Transport-level failures are surfaced as `ConnectionFailed` + // so the caller can fail over to another endpoint. + Error::ConnectionFailed(format!("Cannot connect to server: {}", e)) + } else { + Error::HttpError(e) + } + })?; + + let status = response.status(); + if !status.is_success() { + let text = response.text().await.map_err(Error::HttpError)?; + let body = parse_body_text(&text); + let message = error_message_from_body(&body, status.as_u16()); + return Err(Error::ApiError(message)); + } + + let bytes = response.bytes().await.map_err(Error::HttpError)?; + tokio::fs::write(dest, &bytes).await?; + Ok(bytes.len() as u64) + } } pub struct ConnectionManager { @@ -534,22 +691,10 @@ impl ConnectionManager { pub async fn remove_connection(&mut self, id: &str, path: &Path) -> crate::Result<()> { self.connections.remove(id); // Clear stored credentials from keyring - let _ = keyring_entry(id, "ticket").and_then(|e| { - e.delete_credential() - .map_err(|e| Error::KeyringError(e.to_string())) - }); - let _ = keyring_entry(id, "csrf_token").and_then(|e| { - e.delete_credential() - .map_err(|e| Error::KeyringError(e.to_string())) - }); - let _ = keyring_entry(id, "password").and_then(|e| { - e.delete_credential() - .map_err(|e| Error::KeyringError(e.to_string())) - }); - let _ = keyring_entry(id, "token").and_then(|e| { - e.delete_credential() - .map_err(|e| Error::KeyringError(e.to_string())) - }); + let _ = keyring_delete_credential(id, "ticket"); + let _ = keyring_delete_credential(id, "csrf_token"); + let _ = keyring_delete_credential(id, "password"); + let _ = keyring_delete_credential(id, "token"); if self.active_connection_id.as_deref() == Some(id) { self.active_connection_id = None; } @@ -655,110 +800,116 @@ impl ConnectionManager { // Same-cluster merge: when this connection belongs to the same cluster // as an existing one, fold its endpoint and node list into the - // existing connection and drop it. - let cluster_id = { + // existing connection and drop it. PBS connections are single-host and + // never participate in cluster merging. + let (cluster_id, server_type) = { let conn = self .connections .get(id) .expect("connection existence was checked above"); - conn.config.cluster_id.clone() + ( + conn.config.cluster_id.clone(), + conn.config.server_type.clone(), + ) }; - if let Some(cid) = cluster_id.filter(|cid| !cid.is_empty()) { - let other_id = self - .connections - .iter() - .find(|(other_id, conn)| { - other_id.as_str() != id - && conn.config.cluster_id.as_deref() == Some(cid.as_str()) - }) - .map(|(other_id, _)| other_id.clone()); + if server_type != "pbs" { + if let Some(cid) = cluster_id.filter(|cid| !cid.is_empty()) { + let other_id = self + .connections + .iter() + .find(|(other_id, conn)| { + other_id.as_str() != id + && conn.config.cluster_id.as_deref() == Some(cid.as_str()) + }) + .map(|(other_id, _)| other_id.clone()); - if let Some(other_id) = other_id { - let (this_primary_url, this_primary_node, this_nodes) = { - let conn = self - .connections - .get(id) - .expect("connection existence was checked above"); - ( - conn.config.primary.url.clone(), - conn.config.primary.node.clone(), - conn.config.nodes.clone(), - ) - }; + if let Some(other_id) = other_id { + let (this_primary_url, this_primary_node, this_nodes) = { + let conn = self + .connections + .get(id) + .expect("connection existence was checked above"); + ( + conn.config.primary.url.clone(), + conn.config.primary.node.clone(), + conn.config.nodes.clone(), + ) + }; - { - let other = self - .connections - .get_mut(&other_id) - .expect("merge target was located above"); - // This connection's primary endpoint becomes a fallback on - // the surviving connection, deduplicated case-insensitively. - let url_known = other - .config - .fallbacks - .iter() - .any(|endpoint| endpoint.url.eq_ignore_ascii_case(&this_primary_url)); - if !url_known { - other.config.fallbacks.push(EndpointConfig { - url: this_primary_url.clone(), - node: this_primary_node.clone(), - token: None, - }); - } - // Adopt the merging connection's primary node only when the - // surviving connection has none yet. - if other - .config - .primary - .node - .as_deref() - .map_or(true, str::is_empty) { - other.config.primary.node = this_primary_node; - } - // Merge the node lists (dedup by URL), then re-derive each - // node's primary marker against the surviving connection's - // primary URL. - let other_primary_url = other.config.primary.url.clone(); - for node in this_nodes { - if !other + let other = self + .connections + .get_mut(&other_id) + .expect("merge target was located above"); + // This connection's primary endpoint becomes a fallback on + // the surviving connection, deduplicated case-insensitively. + let url_known = other .config - .nodes + .fallbacks .iter() - .any(|existing| existing.url.eq_ignore_ascii_case(&node.url)) + .any(|endpoint| endpoint.url.eq_ignore_ascii_case(&this_primary_url)); + if !url_known { + other.config.fallbacks.push(EndpointConfig { + url: this_primary_url.clone(), + node: this_primary_node.clone(), + token: None, + }); + } + // Adopt the merging connection's primary node only when the + // surviving connection has none yet. + if other + .config + .primary + .node + .as_deref() + .map_or(true, str::is_empty) { - other.config.nodes.push(node); + other.config.primary.node = this_primary_node; + } + // Merge the node lists (dedup by URL), then re-derive each + // node's primary marker against the surviving connection's + // primary URL. + let other_primary_url = other.config.primary.url.clone(); + for node in this_nodes { + if !other + .config + .nodes + .iter() + .any(|existing| existing.url.eq_ignore_ascii_case(&node.url)) + { + other.config.nodes.push(node); + } + } + for node in &mut other.config.nodes { + node.is_primary = node.url.eq_ignore_ascii_case(&other_primary_url); + } + if other + .config + .primary + .node + .as_deref() + .map_or(true, str::is_empty) + { + if let Some(primary) = + other.config.nodes.iter().find(|node| node.is_primary) + { + other.config.primary.node = Some(primary.name.clone()); + } } } - for node in &mut other.config.nodes { - node.is_primary = node.url.eq_ignore_ascii_case(&other_primary_url); - } - if other - .config - .primary - .node - .as_deref() - .map_or(true, str::is_empty) - { - if let Some(primary) = - other.config.nodes.iter().find(|node| node.is_primary) - { - other.config.primary.node = Some(primary.name.clone()); - } - } - } - self.connections.remove(id); - if self.active_connection_id.as_deref() == Some(id) { - self.active_connection_id = Some(other_id.clone()); + self.connections.remove(id); + if self.active_connection_id.as_deref() == Some(id) { + self.active_connection_id = Some(other_id.clone()); + } + self.persist(path)?; + return Ok(ConnectResult { + connection_id: other_id.clone(), + merged_into: Some(other_id), + status: "connected".to_string(), + }); } - self.persist(path)?; - return Ok(ConnectResult { - connection_id: other_id.clone(), - merged_into: Some(other_id), - status: "connected".to_string(), - }); } } @@ -800,7 +951,11 @@ impl ConnectionManager { let conn = self.connection(id)?; conn.set_endpoint_index(0); conn.set_runtime_status("connected"); - self.discover_nodes(id).await?; + // PBS is single-host: there is no node list or cluster identity to + // discover, so the discovery pass is skipped entirely. + if !conn.config.is_pbs() { + self.discover_nodes(id).await?; + } Ok(()) } @@ -872,10 +1027,17 @@ impl ConnectionManager { }); } - let transport_failed = match self.discover_nodes(connection_id).await { - Ok(_) => false, - Err(Error::ConnectionFailed(_)) => true, - Err(_) => false, + // PBS connections have no discoverable node list, so the status poll + // reports the snapshot and the current runtime status without any + // discovery request. + let transport_failed = if self.connection(connection_id)?.config.is_pbs() { + false + } else { + match self.discover_nodes(connection_id).await { + Ok(_) => false, + Err(Error::ConnectionFailed(_)) => true, + Err(_) => false, + } }; let (primary_url, current_endpoint_url, nodes) = self.status_snapshot(connection_id)?; @@ -1024,22 +1186,10 @@ impl ConnectionManager { }; // Store credentials in keyring for later use - keyring_entry(&connection_id, "ticket").and_then(|e| { - e.set_password(&ticket) - .map_err(|e| Error::KeyringError(e.to_string())) - })?; - keyring_entry(&connection_id, "csrf_token").and_then(|e| { - e.set_password(&csrf_token) - .map_err(|e| Error::KeyringError(e.to_string())) - })?; - keyring_entry(&connection_id, "username").and_then(|e| { - e.set_password(username) - .map_err(|e| Error::KeyringError(e.to_string())) - })?; - keyring_entry(&connection_id, "password").and_then(|e| { - e.set_password(password) - .map_err(|e| Error::KeyringError(e.to_string())) - })?; + keyring_set_password(&connection_id, "ticket", &ticket)?; + keyring_set_password(&connection_id, "csrf_token", &csrf_token)?; + keyring_set_password(&connection_id, "username", username)?; + keyring_set_password(&connection_id, "password", password)?; // Keep the in-memory session of an already-added connection in sync so // requests made right after login have auth available. @@ -1054,7 +1204,12 @@ impl ConnectionManager { }) } - pub async fn login_with_token(&self, url: &str, token: &str) -> crate::Result { + pub async fn login_with_token( + &self, + url: &str, + token: &str, + server_type: &str, + ) -> crate::Result { if url.is_empty() { return Err(Error::InvalidUrl("URL cannot be empty".to_string())); } @@ -1066,9 +1221,16 @@ impl ConnectionManager { let client = build_client()?; - // Validate the token by making an authenticated request - let test_url = format!("{}/api2/json/cluster/status", url); - let auth_header = format!("PVEAPIToken={}", token); + // Validate the token by making an authenticated request. PVE exposes + // `/cluster/status`; PBS (which has no cluster concept) validates + // against `/version` instead. The header prefix follows the server + // type (`PVEAPIToken` vs `PBSAPIToken`). + let server_type = ServerType::from_config(server_type); + let test_url = match server_type { + ServerType::Pbs => format!("{}/api2/json/version", url), + ServerType::Pve => format!("{}/api2/json/cluster/status", url), + }; + let auth_header = format!("{}={}", server_type.token_header_name(), token); let response = client .get(&test_url) @@ -1097,10 +1259,7 @@ impl ConnectionManager { }; // Store token in keyring for later use - keyring_entry(&connection_id, "token").and_then(|e| { - e.set_password(token) - .map_err(|e| Error::KeyringError(e.to_string())) - })?; + keyring_set_password(&connection_id, "token", token)?; Ok(LoginResult { connection_id, @@ -1110,26 +1269,11 @@ impl ConnectionManager { } pub async fn logout(&self, connection_id: &str) -> crate::Result<()> { - let _ = keyring_entry(connection_id, "ticket").and_then(|e| { - e.delete_credential() - .map_err(|e| Error::KeyringError(e.to_string())) - }); - let _ = keyring_entry(connection_id, "csrf_token").and_then(|e| { - e.delete_credential() - .map_err(|e| Error::KeyringError(e.to_string())) - }); - let _ = keyring_entry(connection_id, "password").and_then(|e| { - e.delete_credential() - .map_err(|e| Error::KeyringError(e.to_string())) - }); - let _ = keyring_entry(connection_id, "token").and_then(|e| { - e.delete_credential() - .map_err(|e| Error::KeyringError(e.to_string())) - }); - let _ = keyring_entry(connection_id, "username").and_then(|e| { - e.delete_credential() - .map_err(|e| Error::KeyringError(e.to_string())) - }); + let _ = keyring_delete_credential(connection_id, "ticket"); + let _ = keyring_delete_credential(connection_id, "csrf_token"); + let _ = keyring_delete_credential(connection_id, "password"); + let _ = keyring_delete_credential(connection_id, "token"); + let _ = keyring_delete_credential(connection_id, "username"); Ok(()) } @@ -1144,7 +1288,7 @@ impl ConnectionManager { match entry.get_password() { Ok(ticket) => Ok(Some(ticket)), Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(Error::KeyringError(e.to_string())), + Err(e) => Err(Error::KeyringError(keyring_describe_error(&e))), } } @@ -1156,25 +1300,13 @@ impl ConnectionManager { password: Option<&str>, api_token: Option<&str>, ) -> crate::Result<()> { - keyring_entry(connection_id, "ticket").and_then(|e| { - e.set_password(ticket) - .map_err(|e| Error::KeyringError(e.to_string())) - })?; - keyring_entry(connection_id, "csrf_token").and_then(|e| { - e.set_password(csrf_token) - .map_err(|e| Error::KeyringError(e.to_string())) - })?; + keyring_set_password(connection_id, "ticket", ticket)?; + keyring_set_password(connection_id, "csrf_token", csrf_token)?; if let Some(pw) = password { - keyring_entry(connection_id, "password").and_then(|e| { - e.set_password(pw) - .map_err(|e| Error::KeyringError(e.to_string())) - })?; + keyring_set_password(connection_id, "password", pw)?; } if let Some(tok) = api_token { - keyring_entry(connection_id, "token").and_then(|e| { - e.set_password(tok) - .map_err(|e| Error::KeyringError(e.to_string())) - })?; + keyring_set_password(connection_id, "token", tok)?; } Ok(()) } @@ -1215,7 +1347,7 @@ impl ConnectionManager { Err(keyring::Error::NoEntry) => Err(Error::AuthError( "No stored credentials for re-authentication".to_string(), )), - Err(e) => Err(Error::KeyringError(e.to_string())), + Err(e) => Err(Error::KeyringError(keyring_describe_error(&e))), }, Err(_) => Err(Error::AuthError( "No stored credentials for re-authentication".to_string(), @@ -1244,7 +1376,7 @@ impl ConnectionManager { /// Looks up a connection by id, mapping a missing id to /// [`Error::ConnectionNotFound`]. - fn connection(&self, id: &str) -> crate::Result<&Connection> { + pub(crate) fn connection(&self, id: &str) -> crate::Result<&Connection> { self.connections .get(id) .ok_or_else(|| Error::ConnectionNotFound(id.to_string())) @@ -1266,6 +1398,43 @@ impl ConnectionManager { Ok(conn.config.clone()) } + /// Resolves the auth header to send alongside a WebSocket handshake for + /// `connection_id`, using the same secret resolution as `auth_context()` + /// (token from config/keyring; ticket from session/keyring). + /// + /// Returns `Some((header_name, header_value))` when a secret is available + /// — `Authorization: {PVE|PBS}APIToken=...` for token mode, or + /// `Cookie: {PVE|PBS}AuthCookie=...` for password mode — and `None` when + /// no secret can be resolved. + pub fn auth_header_for(&self, connection_id: &str) -> crate::Result> { + let conn = self.connection(connection_id)?; + let auth = conn.auth_context()?; + match auth.mode { + AuthMode::Token => { + Ok(auth + .token + .as_deref() + .filter(|token| !token.is_empty()) + .map(|token| { + ( + "Authorization".to_string(), + format!("{}={}", auth.server_type.token_header_name(), token), + ) + })) + } + AuthMode::Password => Ok(auth + .ticket + .as_deref() + .filter(|ticket| !ticket.is_empty()) + .map(|ticket| { + ( + "Cookie".to_string(), + format!("{}={}", auth.server_type.cookie_header_name(), ticket), + ) + })), + } + } + /// Validates that `vm_type` is one of the Proxmox VM type path segments /// (`qemu` for VMs, `lxc` for containers). fn validate_vm_type(vm_type: &str) -> crate::Result<()> { diff --git a/src-tauri/src/console_proxy.rs b/src-tauri/src/console_proxy.rs index 4b7150c..524491f 100644 --- a/src-tauri/src/console_proxy.rs +++ b/src-tauri/src/console_proxy.rs @@ -103,8 +103,10 @@ impl ConsoleProxyManager { let ws_url = format!("{}{}", origin, path); // Connect to Proxmox first so an unreachable server fails the command - // instead of leaving a dangling local listener. - let server = timeout(Duration::from_secs(20), connect_ws(&ws_url)) + // instead of leaving a dangling local listener. The console websocket + // authenticates via the ticket in the URL query, so no auth header is + // sent. + let server = timeout(Duration::from_secs(20), connect_ws(&ws_url, None)) .await .map_err(|_| Error::WebSocketError("Timed out opening console proxy".to_string()))? .map_err(|e| Error::WebSocketError(format!("Cannot open console proxy: {}", e)))?; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 35278c5..a4843bf 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -41,6 +41,9 @@ pub enum Error { #[error("Tauri error: {0}")] TauriError(#[from] tauri::Error), + + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), } impl Serialize for Error { diff --git a/src-tauri/src/keyring.rs b/src-tauri/src/keyring.rs new file mode 100644 index 0000000..2f6949c --- /dev/null +++ b/src-tauri/src/keyring.rs @@ -0,0 +1,219 @@ +//! OS credential-store access for connection secrets, hardened against the +//! macOS Keychain's find-then-add behavior. +//! +//! Secrets live in the platform secure store: the macOS Keychain, the Windows +//! Credential Manager, or the Linux kernel keyutils (which needs no daemon and +//! therefore works headless). The store is selected once, on first use, via +//! [`keyring_core::set_default_store`]. +//! +//! # macOS duplicate-item recovery +//! +//! The Keychain backend behind `keyring`/`security-framework` implements +//! "set" as *find then add*: the scoped lookup against the login keychain is +//! attempted first, and when it fails (for any reason) a new item is added. +//! If the login keychain is locked, the lookup fails, macOS prompts to unlock +//! it, and the subsequent add discovers the item created by an earlier login +//! still exists, returning `errSecDuplicateItem`. [`set_password`] detects +//! that OSStatus and recovers by deleting the stale item and retrying once, +//! so re-logins and ticket refreshes succeed after the keychain is unlocked. +//! Failures that persist are mapped to actionable messages via +//! [`describe_error`]. + +use std::sync::OnceLock; + +pub use keyring_core as keyring; + +/// The Keychain/Credential Manager service name under which all Clustri +/// secrets are stored. Each entry's account is `"{connection_id}:{field}"`. +pub const SERVICE: &str = "clustri"; + +/// Initializes the platform credential store, exactly once, before the first +/// entry is created. Subsequent calls return the cached outcome. +fn init_store() -> keyring::Result<()> { + static INIT: OnceLock> = OnceLock::new(); + let cached = INIT.get_or_init(|| { + #[cfg(target_os = "macos")] + let store = apple_native_keyring_store::keychain::Store::new(); + #[cfg(target_os = "windows")] + let store = windows_native_keyring_store::Store::new(); + #[cfg(target_os = "linux")] + let store = linux_keyutils_keyring_store::Store::new(); + #[cfg(any( + target_os = "macos", + target_os = "windows", + target_os = "linux" + ))] + match store { + Ok(s) => { + keyring_core::set_default_store(s); + Ok(()) + } + Err(e) => Err(e.to_string()), + } + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux" + )))] + Err("no keyring store is configured for this platform".to_string()) + }); + match cached { + Ok(()) => Ok(()), + Err(message) => Err(keyring::Error::Invalid( + "store".to_string(), + message.clone(), + )), + } +} + +/// Returns the keyring entry for a connection's field, initializing the +/// platform store on first use. Entry construction failures are mapped to +/// [`crate::Error::KeyringError`] so callers that tolerate a missing store +/// (e.g. headless Linux) can treat them as `None`. +pub fn entry(connection_id: &str, field: &str) -> crate::Result { + init_store().map_err(|e| crate::Error::KeyringError(describe_error(&e)))?; + let key = format!("{}:{}", connection_id, field); + keyring::Entry::new(SERVICE, &key).map_err(|e| crate::Error::KeyringError(describe_error(&e))) +} + +/// Writes a secret for a connection field, recovering from the macOS +/// "item already exists" failure by deleting the stale item and retrying once. +pub fn set_password(connection_id: &str, field: &str, value: &str) -> crate::Result<()> { + let entry = entry(connection_id, field)?; + match entry.set_password(value) { + Ok(()) => Ok(()), + Err(keyring::Error::PlatformFailure(inner)) if is_duplicate_item(inner.as_ref()) => { + // The item exists but the pre-add lookup could not see it (the + // login keychain was locked during the lookup). Once the user has + // unlocked the keychain, deleting and re-adding succeeds. + let _ = entry.delete_credential(); + entry + .set_password(value) + .map_err(|e| crate::Error::KeyringError(describe_error(&e))) + } + Err(e) => Err(crate::Error::KeyringError(describe_error(&e))), + } +} + +/// Deletes a stored secret for a connection field. A missing entry is not an +/// error, so clearing credentials is idempotent. +pub fn delete_credential(connection_id: &str, field: &str) -> crate::Result<()> { + let entry = entry(connection_id, field)?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(crate::Error::KeyringError(describe_error(&e))), + } +} + +/// Maps a keyring error to a message with actionable guidance. On macOS the +/// underlying OSStatus is inspected for the known failure modes (duplicate +/// item, locked keychain, missing entitlement); everything else keeps the +/// platform detail so it can be reported or debugged. +pub fn describe_error(err: &keyring::Error) -> String { + match err { + keyring::Error::PlatformFailure(inner) => { + #[cfg(target_os = "macos")] + { + if let Some(sec_err) = inner.downcast_ref::() { + return match sec_err.code() { + // errSecDuplicateItem: the item exists but could not be + // located for update (locked login keychain during the + // lookup, or a stray item in another keychain). + -25299 => format!( + "macOS Keychain: the stored item already exists and could not \ + be replaced. Your login keychain is likely locked or its \ + password is out of date. Open Keychain Access, unlock the \ + 'login' keychain (or use Edit > Change Password for Keychain \ + 'login'), then try again. If a duplicate '{SERVICE}' item is \ + listed under the iCloud keychain, delete it there as well. \ + ({inner})" + ), + // errSecAuthFailed: the keychain did not unlock. + -25293 => format!( + "macOS Keychain: the login keychain is locked or the password \ + is incorrect. Unlock it in Keychain Access and try again. \ + ({inner})" + ), + // errSecMissingEntitlement: unsigned app access denial. + -34018 => format!( + "macOS Keychain: access was denied because the app is not \ + signed with keychain entitlements. ({inner})" + ), + _ => format!("macOS Keychain error: {inner}"), + }; + } + } + format!("Secure storage error: {inner}") + } + keyring::Error::NoStorageAccess(inner) => format!( + "Secure storage is locked or unavailable: {inner}. Unlock your login keychain \ + (or keyring) and try again." + ), + _ => err.to_string(), + } +} + +/// True when a platform error is the macOS `errSecDuplicateItem` code, which +/// the login keychain reports when an add hits an item that already exists. +fn is_duplicate_item(err: &(dyn std::error::Error + Send + Sync)) -> bool { + #[cfg(target_os = "macos")] + { + if let Some(sec_err) = err.downcast_ref::() { + return sec_err.code() == -25299; + } + } + let _ = err; + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn describe_error_explains_locked_storage() { + let err = keyring::Error::NoStorageAccess(Box::new(std::io::Error::other("locked"))); + let msg = describe_error(&err); + assert!(msg.contains("locked or unavailable"), "{msg}"); + assert!(msg.contains("Unlock"), "{msg}"); + } + + #[test] + fn describe_error_keeps_platform_detail() { + let err = keyring::Error::PlatformFailure(Box::new(std::io::Error::other("boom"))); + let msg = describe_error(&err); + assert!(msg.contains("Secure storage error"), "{msg}"); + assert!(msg.contains("boom"), "{msg}"); + } + + #[test] + fn describe_error_passes_other_variants_through() { + let err = keyring::Error::Invalid("service".to_string(), "cannot be empty".to_string()); + assert_eq!(describe_error(&err), err.to_string()); + } + + #[test] + fn set_get_delete_round_trip() { + let id = uuid::Uuid::new_v4().to_string(); + let field = "unit"; + let value = "round-trip-secret"; + set_password(&id, field, value).expect("set_password should succeed"); + let entry = entry(&id, field).expect("entry should be constructible"); + assert_eq!( + entry.get_password().expect("get_password should succeed"), + value + ); + delete_credential(&id, field).expect("delete_credential should succeed"); + match entry.get_password() { + Err(keyring::Error::NoEntry) => {} + other => panic!("expected NoEntry after delete, got {other:?}"), + } + } + + #[test] + fn delete_credential_is_idempotent() { + let id = uuid::Uuid::new_v4().to_string(); + delete_credential(&id, "unit").expect("deleting a missing entry should be a no-op"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9cbc747..138e6f1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,15 +9,20 @@ use tokio::sync::RwLock; mod connection; mod console_proxy; mod error; +mod keyring; +mod pbs; mod proxmox; pub mod tls; mod websocket; pub use connection::{ - api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult, + api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult, ServerType, }; pub use console_proxy::ConsoleProxyInfo; pub use error::Error; +pub use pbs::{ + PbsBackupGroup, PbsDatastore, PbsJob, PbsNodeStatus, PbsSnapshot, PbsSnapshotFile, PbsVersion, +}; pub use proxmox::{ AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig, RestoreConfig, UpdateVMConfig, @@ -48,6 +53,23 @@ pub struct ConnectionConfig { pub nodes: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub cluster_id: Option, + #[serde(default = "default_server_type")] + pub server_type: String, // "pve" | "pbs" +} + +/// Default server type for connections added without an explicit value. PVE is +/// the historical behavior and remains the default so older persisted configs +/// (which have no `serverType` field) keep working. +fn default_server_type() -> String { + "pve".to_string() +} + +impl ConnectionConfig { + /// True when this connection targets a Proxmox Backup Server (PBS) instead + /// of a Proxmox VE cluster. + pub fn is_pbs(&self) -> bool { + self.server_type == "pbs" + } } /// A node discovered in the cluster connected through a @@ -299,7 +321,14 @@ async fn get_tasks( connection_id: String, ) -> Result> { let manager = state.connection_manager.read().await; - manager.get_tasks(&connection_id).await + // PBS exposes its task list under `/nodes/localhost/tasks` with a + // different payload shape than the PVE `/cluster/tasks` endpoint, so the + // server type selects the backing method. + if manager.is_pbs(&connection_id)? { + manager.pbs_get_tasks(&connection_id).await + } else { + manager.get_tasks(&connection_id).await + } } #[tauri::command] @@ -638,9 +667,10 @@ async fn login_with_token( state: tauri::State<'_, AppState>, url: String, token: String, + server_type: String, ) -> Result { let manager = state.connection_manager.read().await; - manager.login_with_token(&url, &token).await + manager.login_with_token(&url, &token, &server_type).await } #[tauri::command] @@ -716,8 +746,18 @@ async fn connect_websocket( url: String, app_handle: tauri::AppHandle, ) -> Result<()> { + // Resolve the auth header server-side from the connection's stored + // credentials (token from config/keyring, ticket from session/keyring) so + // the frontend never has to hold secrets. A connection with no resolvable + // secret connects without an auth header. + let auth_header = { + let manager = state.connection_manager.read().await; + manager.auth_header_for(&connection_id).ok().flatten() + }; let mut ws_manager = state.ws_manager.write().await; - ws_manager.connect(connection_id, url, app_handle).await + ws_manager + .connect(connection_id, url, auth_header, app_handle) + .await } #[tauri::command] @@ -847,6 +887,222 @@ async fn delete_backup( manager.delete_backup(&connection_id, &volid).await } +// Proxmox Backup Server (PBS) commands +#[tauri::command] +async fn get_pbs_datastores( + state: tauri::State<'_, AppState>, + connection_id: String, +) -> Result> { + let manager = state.connection_manager.read().await; + manager.pbs_get_datastores(&connection_id).await +} + +#[tauri::command] +async fn get_pbs_version( + state: tauri::State<'_, AppState>, + connection_id: String, +) -> Result { + let manager = state.connection_manager.read().await; + manager.pbs_get_version(&connection_id).await +} + +#[tauri::command] +async fn get_pbs_node_status( + state: tauri::State<'_, AppState>, + connection_id: String, +) -> Result { + let manager = state.connection_manager.read().await; + manager.pbs_get_node_status(&connection_id).await +} + +#[tauri::command] +async fn get_pbs_groups( + state: tauri::State<'_, AppState>, + connection_id: String, + store: String, +) -> Result> { + let manager = state.connection_manager.read().await; + manager.pbs_get_groups(&connection_id, &store).await +} + +#[tauri::command] +async fn get_pbs_snapshots( + state: tauri::State<'_, AppState>, + connection_id: String, + store: String, + backup_id: String, + backup_type: String, +) -> Result> { + let manager = state.connection_manager.read().await; + manager + .pbs_get_snapshots(&connection_id, &store, &backup_id, &backup_type) + .await +} + +#[tauri::command] +async fn get_pbs_snapshot_files( + state: tauri::State<'_, AppState>, + connection_id: String, + store: String, + backup_id: String, + backup_type: String, + backup_time: i64, +) -> Result> { + let manager = state.connection_manager.read().await; + manager + .pbs_get_snapshot_files(&connection_id, &store, &backup_id, &backup_type, backup_time) + .await +} + +#[tauri::command] +// +// The parameter list is the Tauri invoke IPC contract with the frontend +// (`downloadPbsSnapshotFile` in src/lib/tauri.ts), so the args cannot be +// grouped without changing the frontend call site. +#[allow(clippy::too_many_arguments)] +async fn download_pbs_snapshot_file( + state: tauri::State<'_, AppState>, + connection_id: String, + store: String, + backup_id: String, + backup_type: String, + backup_time: i64, + file_name: String, + decoded: bool, + save_path: String, +) -> Result { + let manager = state.connection_manager.read().await; + manager + .pbs_download_snapshot_file( + &connection_id, + &store, + &backup_id, + &backup_type, + backup_time, + &file_name, + decoded, + &save_path, + ) + .await +} + +#[tauri::command] +async fn delete_pbs_snapshot( + state: tauri::State<'_, AppState>, + connection_id: String, + store: String, + backup_id: String, + backup_type: String, + backup_time: i64, +) -> Result<()> { + let manager = state.connection_manager.read().await; + manager + .pbs_delete_snapshot(&connection_id, &store, &backup_id, &backup_type, backup_time) + .await +} + +#[tauri::command] +async fn delete_pbs_group( + state: tauri::State<'_, AppState>, + connection_id: String, + store: String, + backup_id: String, + backup_type: String, +) -> Result<()> { + let manager = state.connection_manager.read().await; + manager + .pbs_delete_group(&connection_id, &store, &backup_id, &backup_type) + .await +} + +#[tauri::command] +async fn run_pbs_verify( + state: tauri::State<'_, AppState>, + connection_id: String, + store: String, +) -> Result { + let manager = state.connection_manager.read().await; + manager.pbs_run_verify(&connection_id, &store).await +} + +#[tauri::command] +// +// The parameter list is the Tauri invoke IPC contract with the frontend +// (`runPbsPrune` in src/lib/tauri.ts), so the args cannot be grouped without +// changing the frontend call site. +#[allow(clippy::too_many_arguments)] +async fn run_pbs_prune( + state: tauri::State<'_, AppState>, + connection_id: String, + store: String, + keep_last: Option, + keep_daily: Option, + keep_weekly: Option, + keep_monthly: Option, + keep_yearly: Option, + dry_run: bool, +) -> Result { + let manager = state.connection_manager.read().await; + manager + .pbs_run_prune( + &connection_id, + &store, + keep_last, + keep_daily, + keep_weekly, + keep_monthly, + keep_yearly, + dry_run, + ) + .await +} + +#[tauri::command] +async fn run_pbs_gc( + state: tauri::State<'_, AppState>, + connection_id: String, + store: String, +) -> Result { + let manager = state.connection_manager.read().await; + manager.pbs_run_gc(&connection_id, &store).await +} + +#[tauri::command] +async fn get_pbs_verify_jobs( + state: tauri::State<'_, AppState>, + connection_id: String, + store: Option, +) -> Result> { + let manager = state.connection_manager.read().await; + manager + .pbs_get_verify_jobs(&connection_id, store.as_deref()) + .await +} + +#[tauri::command] +async fn get_pbs_prune_jobs( + state: tauri::State<'_, AppState>, + connection_id: String, + store: Option, +) -> Result> { + let manager = state.connection_manager.read().await; + manager + .pbs_get_prune_jobs(&connection_id, store.as_deref()) + .await +} + +#[tauri::command] +async fn get_pbs_gc_jobs( + state: tauri::State<'_, AppState>, + connection_id: String, + store: Option, +) -> Result> { + let manager = state.connection_manager.read().await; + manager + .pbs_get_gc_jobs(&connection_id, store.as_deref()) + .await +} + #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TrayConnectionInfo { @@ -975,8 +1231,24 @@ pub fn run() { run_backup, restore_backup, delete_backup, + get_pbs_datastores, + get_pbs_version, + get_pbs_node_status, + get_pbs_groups, + get_pbs_snapshots, + get_pbs_snapshot_files, + download_pbs_snapshot_file, + delete_pbs_snapshot, + delete_pbs_group, + run_pbs_verify, + run_pbs_prune, + run_pbs_gc, + get_pbs_verify_jobs, + get_pbs_prune_jobs, + get_pbs_gc_jobs, update_tray_menu, ]) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { // Build the system tray menu let show_hide = MenuItemBuilder::new("Show / Hide") diff --git a/src-tauri/src/pbs.rs b/src-tauri/src/pbs.rs new file mode 100644 index 0000000..15ce983 --- /dev/null +++ b/src-tauri/src/pbs.rs @@ -0,0 +1,756 @@ +//! Proxmox Backup Server (PBS) backend. +//! +//! PBS shares the JSON `{data}` envelope and the token/ticket authentication +//! model with Proxmox VE, but it is single-host and exposes its own endpoint +//! set. All datastore operations live here; the HTTP plumbing (auth headers, +//! endpoint rotation, envelope unwrapping, binary downloads) is shared from +//! `crate::connection`. +//! +//! The PBS API reports kebab-case JSON keys. The public structs serialize as +//! camelCase for the frontend, so the ones deserialized straight from a +//! response use `rename_all(serialize = "camelCase", deserialize = +//! "kebab-case")`; the datastore and gc-status structs are assembled from raw +//! kebab-case intermediate structs and only ever serialize to the frontend. + +use crate::connection::{parse_api, ConnectionManager}; +use crate::proxmox::Task; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +// --------------------------------------------------------------------------- +// Public types (frontend-facing camelCase shapes) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PbsDatastore { + pub store: String, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub backend_type: Option, + #[serde(default)] + pub mount_status: Option, + #[serde(default)] + pub maintenance: Option, + #[serde(default)] + pub total: Option, + #[serde(default)] + pub used: Option, + #[serde(default)] + pub avail: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub estimated_full_date: Option, + #[serde(default)] + pub history: Option>, + #[serde(default)] + pub gc_status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PbsGcStatus { + #[serde(default)] + pub disk_bytes: Option, + #[serde(default)] + pub disk_chunks: Option, + #[serde(default)] + pub index_data_bytes: Option, + #[serde(default)] + pub index_file_count: Option, + #[serde(default)] + pub pending_bytes: Option, + #[serde(default)] + pub pending_chunks: Option, + #[serde(default)] + pub removed_bad: Option, + #[serde(default)] + pub removed_bytes: Option, + #[serde(default)] + pub removed_chunks: Option, + #[serde(default)] + pub still_bad: Option, + #[serde(default)] + pub cache_hits: Option, + #[serde(default)] + pub cache_misses: Option, + #[serde(default)] + pub upid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PbsVersion { + pub version: String, + pub release: String, + pub repoid: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))] +pub struct PbsNodeStatus { + #[serde(default)] + pub cpu: Option, + #[serde(default)] + pub loadavg: Option>, + #[serde(default)] + pub uptime: Option, + #[serde(default)] + pub memory: Option, + #[serde(default)] + pub root: Option, + #[serde(default)] + pub swap: Option, + #[serde(default)] + pub cpuinfo: Option, + #[serde(default)] + pub current_kernel: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PbsMem { + #[serde(default)] + pub free: Option, + #[serde(default)] + pub total: Option, + #[serde(default)] + pub used: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PbsCpuInfo { + #[serde(default)] + pub cpus: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub sockets: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PbsKernel { + #[serde(default)] + pub machine: Option, + #[serde(default)] + pub release: Option, + #[serde(default)] + pub sysname: Option, + #[serde(default)] + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))] +pub struct PbsBackupGroup { + pub backup_id: String, + pub backup_type: String, + #[serde(default)] + pub backup_count: Option, + #[serde(default)] + pub last_backup: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub files: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))] +pub struct PbsSnapshot { + pub backup_id: String, + pub backup_type: String, + pub backup_time: i64, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub protected: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub files: Option>, + #[serde(default)] + pub fingerprint: Option, + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub verification: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PbsVerification { + #[serde(default)] + pub state: Option, + #[serde(default)] + pub upid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))] +pub struct PbsSnapshotFile { + pub filename: String, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub crypt_mode: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))] +pub struct PbsJob { + pub id: String, + #[serde(default)] + pub store: Option, + #[serde(default)] + pub schedule: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub disable: Option, + #[serde(default)] + pub last_run_state: Option, + #[serde(default)] + pub last_run_endtime: Option, + #[serde(default)] + pub next_run: Option, + #[serde(default)] + pub keep_last: Option, + #[serde(default)] + pub keep_daily: Option, + #[serde(default)] + pub keep_weekly: Option, + #[serde(default)] + pub keep_monthly: Option, + #[serde(default)] + pub keep_yearly: Option, + #[serde(default)] + pub ignore_verified: Option, + #[serde(default)] + pub max_depth: Option, +} + +// --------------------------------------------------------------------------- +// Raw kebab-case response shapes (not serialized to the frontend) +// --------------------------------------------------------------------------- + +/// Raw `/status/datastore-usage` entry. The usage endpoint reports kebab-case +/// keys and nests the cache stats inside `gc-status.cache-stats`, so the +/// public [`PbsDatastore`]/[`PbsGcStatus`] structs are built from this by +/// hand rather than deserialized directly. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct PbsDatastoreUsageRaw { + store: String, + #[serde(default)] + backend_type: Option, + #[serde(default)] + mount_status: Option, + #[serde(default)] + avail: Option, + #[serde(default)] + total: Option, + #[serde(default)] + used: Option, + #[serde(default)] + error: Option, + #[serde(default)] + estimated_full_date: Option, + #[serde(default)] + history: Option>, + #[serde(default)] + gc_status: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct PbsGcStatusRaw { + #[serde(default)] + disk_bytes: Option, + #[serde(default)] + disk_chunks: Option, + #[serde(default)] + index_data_bytes: Option, + #[serde(default)] + index_file_count: Option, + #[serde(default)] + pending_bytes: Option, + #[serde(default)] + pending_chunks: Option, + #[serde(default)] + removed_bad: Option, + #[serde(default)] + removed_bytes: Option, + #[serde(default)] + removed_chunks: Option, + #[serde(default)] + still_bad: Option, + #[serde(default)] + cache_stats: Option, + #[serde(default)] + upid: Option, +} + +#[derive(Debug, Deserialize)] +struct PbsCacheStatsRaw { + #[serde(default)] + hits: Option, + #[serde(default)] + misses: Option, +} + +/// Raw `/admin/datastore` entry carrying the static datastore config that the +/// usage endpoint does not report (comment, maintenance). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct PbsDatastoreConfigRaw { + store: String, + #[serde(default)] + comment: Option, + #[serde(default)] + backend_type: Option, + #[serde(default)] + mount_status: Option, + #[serde(default)] + maintenance: Option, +} + +/// Raw `/admin/gc` job entry. GC jobs carry no `id` — the store identifies the +/// job — so `id` falls back to `store` when mapping to [`PbsJob`]. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct PbsGcJobRaw { + store: String, + #[serde(default)] + schedule: Option, + #[serde(default)] + comment: Option, + #[serde(default)] + disable: Option, + #[serde(default)] + last_run_state: Option, + #[serde(default)] + last_run_endtime: Option, + #[serde(default)] + next_run: Option, + #[serde(default)] + keep_last: Option, + #[serde(default)] + keep_daily: Option, + #[serde(default)] + keep_weekly: Option, + #[serde(default)] + keep_monthly: Option, + #[serde(default)] + keep_yearly: Option, + #[serde(default)] + ignore_verified: Option, + #[serde(default)] + max_depth: Option, +} + +impl From for PbsDatastore { + fn from(raw: PbsDatastoreUsageRaw) -> Self { + PbsDatastore { + store: raw.store, + comment: None, + backend_type: raw.backend_type, + mount_status: raw.mount_status, + maintenance: None, + total: raw.total, + used: raw.used, + avail: raw.avail, + error: raw.error, + estimated_full_date: raw.estimated_full_date, + history: raw.history, + gc_status: raw.gc_status.map(Into::into), + } + } +} + +impl From for PbsGcStatus { + fn from(raw: PbsGcStatusRaw) -> Self { + PbsGcStatus { + disk_bytes: raw.disk_bytes, + disk_chunks: raw.disk_chunks, + index_data_bytes: raw.index_data_bytes, + index_file_count: raw.index_file_count, + pending_bytes: raw.pending_bytes, + pending_chunks: raw.pending_chunks, + removed_bad: raw.removed_bad, + removed_bytes: raw.removed_bytes, + removed_chunks: raw.removed_chunks, + still_bad: raw.still_bad, + cache_hits: raw.cache_stats.as_ref().and_then(|stats| stats.hits), + cache_misses: raw.cache_stats.as_ref().and_then(|stats| stats.misses), + upid: raw.upid, + } + } +} + +impl From for PbsJob { + fn from(raw: PbsGcJobRaw) -> Self { + PbsJob { + id: raw.store.clone(), + store: Some(raw.store), + schedule: raw.schedule, + comment: raw.comment, + disable: raw.disable, + last_run_state: raw.last_run_state, + last_run_endtime: raw.last_run_endtime, + next_run: raw.next_run, + keep_last: raw.keep_last, + keep_daily: raw.keep_daily, + keep_weekly: raw.keep_weekly, + keep_monthly: raw.keep_monthly, + keep_yearly: raw.keep_yearly, + ignore_verified: raw.ignore_verified, + max_depth: raw.max_depth, + } + } +} + +// --------------------------------------------------------------------------- +// PBS API methods +// --------------------------------------------------------------------------- + +impl ConnectionManager { + /// True when the connection targets a PBS server rather than a PVE + /// cluster. + pub fn is_pbs(&self, connection_id: &str) -> crate::Result { + Ok(self.connection(connection_id)?.config.server_type == "pbs") + } + + /// Lists the datastores with their live usage from `/status/datastore-usage`, + /// then merges the static datastore config (`/admin/datastore`: comment, + /// maintenance, mount status) onto each entry by `store`. A failure of the + /// usage call propagates; a failure of the config call degrades to the + /// usage-only list. + pub async fn pbs_get_datastores(&self, connection_id: &str) -> crate::Result> { + let conn = self.connection(connection_id)?; + let data = conn + .request(Method::GET, "/status/datastore-usage", &[], None) + .await?; + let usage: Vec = parse_api("/status/datastore-usage", data)?; + let mut datastores: Vec = usage.into_iter().map(Into::into).collect(); + + if let Ok(config_data) = conn.request(Method::GET, "/admin/datastore", &[], None).await { + if let Ok(configs) = + parse_api::>("/admin/datastore", config_data) + { + for config in configs { + if let Some(datastore) = datastores + .iter_mut() + .find(|datastore| datastore.store == config.store) + { + // Only overwrite with fields the config actually + // reports, so usage-derived values survive an omitted + // key. + if config.comment.is_some() { + datastore.comment = config.comment; + } + if config.backend_type.is_some() { + datastore.backend_type = config.backend_type; + } + if config.mount_status.is_some() { + datastore.mount_status = config.mount_status; + } + if config.maintenance.is_some() { + datastore.maintenance = config.maintenance; + } + } + } + } + } + Ok(datastores) + } + + /// Fetches the server version information. + pub async fn pbs_get_version(&self, connection_id: &str) -> crate::Result { + let conn = self.connection(connection_id)?; + let data = conn.request(Method::GET, "/version", &[], None).await?; + parse_api("/version", data) + } + + /// Fetches the resource usage of the local node. PBS is single-host, so the + /// `localhost` node is always the one being managed. + pub async fn pbs_get_node_status(&self, connection_id: &str) -> crate::Result { + let conn = self.connection(connection_id)?; + let data = conn + .request(Method::GET, "/nodes/localhost/status", &[], None) + .await?; + parse_api("/nodes/localhost/status", data) + } + + /// Lists the backup groups (per `backup-id`/`backup-type`) of a datastore. + pub async fn pbs_get_groups( + &self, + connection_id: &str, + store: &str, + ) -> crate::Result> { + let conn = self.connection(connection_id)?; + let path = format!("/admin/datastore/{}/groups", store); + let data = conn.request(Method::GET, &path, &[], None).await?; + parse_api(&path, data) + } + + /// Lists the snapshots of one backup group. + pub async fn pbs_get_snapshots( + &self, + connection_id: &str, + store: &str, + backup_id: &str, + backup_type: &str, + ) -> crate::Result> { + let conn = self.connection(connection_id)?; + let path = format!("/admin/datastore/{}/snapshots", store); + let query = [ + ("backup-id", backup_id.to_string()), + ("backup-type", backup_type.to_string()), + ]; + let data = conn.request(Method::GET, &path, &query, None).await?; + parse_api(&path, data) + } + + /// Lists the files of one snapshot. + pub async fn pbs_get_snapshot_files( + &self, + connection_id: &str, + store: &str, + backup_id: &str, + backup_type: &str, + backup_time: i64, + ) -> crate::Result> { + let conn = self.connection(connection_id)?; + let path = format!("/admin/datastore/{}/files", store); + let query = [ + ("backup-id", backup_id.to_string()), + ("backup-type", backup_type.to_string()), + ("backup-time", backup_time.to_string()), + ]; + let data = conn.request(Method::GET, &path, &query, None).await?; + parse_api(&path, data) + } + + /// Streams a snapshot file to `save_path` and returns the path. `decoded` + /// selects the `download-decoded` endpoint (raw plaintext archive bytes, + /// only available for unencrypted datastores) over the plain `download` + /// endpoint (raw archive bytes, possibly encrypted). + // + // The argument list mirrors the `download_pbs_snapshot_file` Tauri command + // (the invoke IPC contract), so it cannot be grouped without breaking the + // frontend call sites. + #[allow(clippy::too_many_arguments)] + pub async fn pbs_download_snapshot_file( + &self, + connection_id: &str, + store: &str, + backup_id: &str, + backup_type: &str, + backup_time: i64, + file_name: &str, + decoded: bool, + save_path: &str, + ) -> crate::Result { + let conn = self.connection(connection_id)?; + let endpoint = if decoded { "download-decoded" } else { "download" }; + let path = format!("/admin/datastore/{}/{}", store, endpoint); + let query = [ + ("backup-id", backup_id.to_string()), + ("backup-type", backup_type.to_string()), + ("backup-time", backup_time.to_string()), + ("file-name", file_name.to_string()), + ]; + conn.download_to_file(&path, &query, Path::new(save_path)) + .await?; + Ok(save_path.to_string()) + } + + /// Deletes a single snapshot. + pub async fn pbs_delete_snapshot( + &self, + connection_id: &str, + store: &str, + backup_id: &str, + backup_type: &str, + backup_time: i64, + ) -> crate::Result<()> { + let conn = self.connection(connection_id)?; + let path = format!("/admin/datastore/{}/snapshots", store); + let query = [ + ("backup-id", backup_id.to_string()), + ("backup-type", backup_type.to_string()), + ("backup-time", backup_time.to_string()), + ]; + conn.request(Method::DELETE, &path, &query, None).await?; + Ok(()) + } + + /// Deletes a whole backup group (all of its snapshots). + pub async fn pbs_delete_group( + &self, + connection_id: &str, + store: &str, + backup_id: &str, + backup_type: &str, + ) -> crate::Result<()> { + let conn = self.connection(connection_id)?; + let path = format!("/admin/datastore/{}/groups", store); + let query = [ + ("backup-id", backup_id.to_string()), + ("backup-type", backup_type.to_string()), + ]; + conn.request(Method::DELETE, &path, &query, None).await?; + Ok(()) + } + + /// Starts a verification task for a datastore and returns the UPID. + pub async fn pbs_run_verify(&self, connection_id: &str, store: &str) -> crate::Result { + let conn = self.connection(connection_id)?; + let path = format!("/admin/datastore/{}/verify", store); + let form = [("store", store.to_string())]; + let data = conn.request(Method::POST, &path, &[], Some(&form)).await?; + parse_api(&path, data) + } + + /// Starts a prune task for a datastore and returns the UPID. Only the + /// provided keep-* retention fields are sent; `dry_run` marks the run as + /// a simulation. + // + // The argument list mirrors the `run_pbs_prune` Tauri command (the invoke + // IPC contract), so it cannot be grouped without breaking the frontend + // call sites. + #[allow(clippy::too_many_arguments)] + pub async fn pbs_run_prune( + &self, + connection_id: &str, + store: &str, + keep_last: Option, + keep_daily: Option, + keep_weekly: Option, + keep_monthly: Option, + keep_yearly: Option, + dry_run: bool, + ) -> crate::Result { + let conn = self.connection(connection_id)?; + let path = format!("/admin/datastore/{}/prune-datastore", store); + let mut form: Vec<(&str, String)> = vec![("store", store.to_string())]; + if let Some(keep) = keep_last { + form.push(("keep-last", keep.to_string())); + } + if let Some(keep) = keep_daily { + form.push(("keep-daily", keep.to_string())); + } + if let Some(keep) = keep_weekly { + form.push(("keep-weekly", keep.to_string())); + } + if let Some(keep) = keep_monthly { + form.push(("keep-monthly", keep.to_string())); + } + if let Some(keep) = keep_yearly { + form.push(("keep-yearly", keep.to_string())); + } + if dry_run { + form.push(("dry-run", "1".to_string())); + } + let data = conn.request(Method::POST, &path, &[], Some(&form)).await?; + parse_api(&path, data) + } + + /// Starts a garbage-collection task for a datastore and returns the UPID. + pub async fn pbs_run_gc(&self, connection_id: &str, store: &str) -> crate::Result { + let conn = self.connection(connection_id)?; + let path = format!("/admin/datastore/{}/gc", store); + let form = [("store", store.to_string())]; + let data = conn.request(Method::POST, &path, &[], Some(&form)).await?; + parse_api(&path, data) + } + + /// Lists the verification jobs, optionally filtered by datastore. + pub async fn pbs_get_verify_jobs( + &self, + connection_id: &str, + store: Option<&str>, + ) -> crate::Result> { + let conn = self.connection(connection_id)?; + let query = store + .map(|store| vec![("store", store.to_string())]) + .unwrap_or_default(); + let data = conn.request(Method::GET, "/admin/verify", &query, None).await?; + parse_api("/admin/verify", data) + } + + /// Lists the prune jobs, optionally filtered by datastore. + pub async fn pbs_get_prune_jobs( + &self, + connection_id: &str, + store: Option<&str>, + ) -> crate::Result> { + let conn = self.connection(connection_id)?; + let query = store + .map(|store| vec![("store", store.to_string())]) + .unwrap_or_default(); + let data = conn.request(Method::GET, "/admin/prune", &query, None).await?; + parse_api("/admin/prune", data) + } + + /// Lists the garbage-collection jobs, optionally filtered by datastore. + /// GC jobs carry no `id` of their own, so the datastore name is used. + pub async fn pbs_get_gc_jobs( + &self, + connection_id: &str, + store: Option<&str>, + ) -> crate::Result> { + let conn = self.connection(connection_id)?; + let query = store + .map(|store| vec![("store", store.to_string())]) + .unwrap_or_default(); + let data = conn.request(Method::GET, "/admin/gc", &query, None).await?; + let raw: Vec = parse_api("/admin/gc", data)?; + Ok(raw.into_iter().map(PbsJob::from).collect()) + } + + /// Lists the tasks running on the local PBS node, mapping the kebab-case + /// `worker-type`/`worker-id` keys and the PBS task status codes onto the + /// shared [`Task`] shape (the same struct the PVE task list uses). + pub async fn pbs_get_tasks(&self, connection_id: &str) -> crate::Result> { + let conn = self.connection(connection_id)?; + let data = conn + .request(Method::GET, "/nodes/localhost/tasks", &[], None) + .await?; + let entries: Vec = parse_api("/nodes/localhost/tasks", data)?; + let mut tasks = Vec::with_capacity(entries.len()); + for entry in entries { + // PBS reports `running` while a task is active and `ok` / + // `warning` / `error` once it finished. `ok` is mapped onto the + // PVE-style `exitstatus` so the shared task row renders the same + // way for both platforms. + let (status, exitstatus) = match entry["status"].as_str() { + Some("running") => (Some("running".to_string()), None), + Some("ok") => (None, Some("OK".to_string())), + Some("warning") => (None, Some("WARNING".to_string())), + Some("error") => (None, Some("ERROR".to_string())), + _ => (None, None), + }; + tasks.push(Task { + upid: entry["upid"].as_str().unwrap_or("").to_string(), + node: entry["node"].as_str().unwrap_or("").to_string(), + pid: entry["pid"].as_u64().unwrap_or(0) as u32, + pstart: entry["pstart"].as_u64().unwrap_or(0), + starttime: entry["starttime"].as_u64().unwrap_or(0), + endtime: entry["endtime"].as_u64(), + r#type: entry["worker-type"].as_str().unwrap_or("").to_string(), + id: entry["worker-id"].as_str().unwrap_or("").to_string(), + user: entry["user"].as_str().unwrap_or("").to_string(), + status, + exitstatus, + }); + } + Ok(tasks) + } +} diff --git a/src-tauri/src/websocket.rs b/src-tauri/src/websocket.rs index a31ae70..28fcd10 100644 --- a/src-tauri/src/websocket.rs +++ b/src-tauri/src/websocket.rs @@ -17,13 +17,26 @@ use crate::error::Error; /// rejects the self-signed certificates typical of home-lab Proxmox servers. /// As with the HTTP transport, the application enforces trust at its own layer /// (TOFU pinning in `tls.rs`), so the transport here accepts any certificate. +/// +/// When `auth_header` is `Some((name, value))` the header is attached to the +/// handshake request (e.g. `("Cookie", "PVEAuthCookie=...")` or +/// `("Authorization", "PVEAPIToken=...")`). Unparseable header names/values +/// are silently ignored so a bad secret never breaks the connection outright. pub async fn connect_ws( url: &str, + auth_header: Option<(String, String)>, ) -> crate::Result>> { - let request = url + let mut request = url .into_client_request() .map_err(|e| Error::WebSocketError(e.to_string()))?; + if let Some((name, value)) = auth_header { + if let Ok(name) = http::HeaderName::from_bytes(name.as_bytes()) { + if let Ok(value) = http::HeaderValue::from_str(&value) { + request.headers_mut().insert(name, value); + } + } + } let is_wss = request.uri().scheme_str() == Some("wss"); let connector = if is_wss { @@ -90,11 +103,13 @@ impl WebSocketManager { /// Connect to a Proxmox WebSocket URL for the given connection ID. /// /// Messages are forwarded as Tauri events via `app_handle`. If a connection - /// already exists for this ID, it is disconnected first. + /// already exists for this ID, it is disconnected first. `auth_header` + /// (when present) is attached to every connection attempt's handshake. pub async fn connect( &mut self, connection_id: String, url: String, + auth_header: Option<(String, String)>, app_handle: tauri::AppHandle, ) -> crate::Result<()> { // Disconnect any existing connection for this ID @@ -116,7 +131,7 @@ impl WebSocketManager { _ = shutdown_rx.recv() => { break; } - result = connect_and_run(&cid, &ws_url, &app_handle) => { + result = connect_and_run(&cid, &ws_url, auth_header.clone(), &app_handle) => { match result { Ok(()) => { reconnect_delay = Duration::from_secs(1); @@ -170,9 +185,10 @@ impl WebSocketManager { async fn connect_and_run( connection_id: &str, url: &str, + auth_header: Option<(String, String)>, app_handle: &tauri::AppHandle, ) -> crate::Result<()> { - let ws_stream = connect_ws(url).await?; + let ws_stream = connect_ws(url, auth_header).await?; let cid = connection_id.to_string(); let (mut write, mut read) = ws_stream.split(); diff --git a/src-tauri/tests/api_client.rs b/src-tauri/tests/api_client.rs index ef0e2ee..de0847c 100644 --- a/src-tauri/tests/api_client.rs +++ b/src-tauri/tests/api_client.rs @@ -5,7 +5,7 @@ //! error mapping, query parameters, and URL construction. use httpmock::prelude::*; -use clustri::{api_request, AuthContext, AuthMode, Error}; +use clustri::{api_request, AuthContext, AuthMode, Error, ServerType}; use reqwest::Client; use reqwest::Method as RMethod; @@ -18,6 +18,7 @@ fn token_auth() -> AuthContext { token: Some("root@pam!test-token".to_string()), ticket: None, csrf_token: None, + server_type: ServerType::Pve, } } @@ -27,6 +28,7 @@ fn password_auth() -> AuthContext { token: None, ticket: Some(TICKET.to_string()), csrf_token: Some(CSRF_TOKEN.to_string()), + server_type: ServerType::Pve, } } diff --git a/src-tauri/tests/api_commands.rs b/src-tauri/tests/api_commands.rs index 90debd1..d172221 100644 --- a/src-tauri/tests/api_commands.rs +++ b/src-tauri/tests/api_commands.rs @@ -39,6 +39,7 @@ async fn setup_manager( username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) diff --git a/src-tauri/tests/api_console.rs b/src-tauri/tests/api_console.rs index 2263b27..b1eee04 100644 --- a/src-tauri/tests/api_console.rs +++ b/src-tauri/tests/api_console.rs @@ -36,6 +36,7 @@ async fn setup_manager(url: &str, token: &str) -> (ConnectionManager, tempfile:: username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) diff --git a/src-tauri/tests/api_disk_network.rs b/src-tauri/tests/api_disk_network.rs index 8dcdf5f..8932da4 100644 --- a/src-tauri/tests/api_disk_network.rs +++ b/src-tauri/tests/api_disk_network.rs @@ -45,6 +45,7 @@ async fn setup_manager( username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) diff --git a/src-tauri/tests/api_pbs.rs b/src-tauri/tests/api_pbs.rs new file mode 100644 index 0000000..362f268 --- /dev/null +++ b/src-tauri/tests/api_pbs.rs @@ -0,0 +1,586 @@ +//! Integration tests for the Proxmox Backup Server (PBS) backend. +//! +//! These tests exercise the `pbs_*` `ConnectionManager` methods against a +//! local HTTP mock server: the `PBSAPIToken`/`PBSAuthCookie` auth headers, +//! datastore usage+config merging, the datastore/group/snapshot read +//! endpoints, the snapshot/group deletions, the verify/prune/gc task +//! launches, the binary file download, the kebab-case `worker-*` task +//! mapping, and the PBS-specific connect/login flows (which skip cluster +//! discovery). + +use httpmock::prelude::*; +use clustri::{ConnectionConfig, ConnectionManager, EndpointConfig}; + +const TOKEN: &str = "root@pam!pbs-token"; + +/// Builds a `ConnectionManager` with a single token-mode PBS connection whose +/// primary endpoint points at the mock server. +async fn setup_manager(url: &str, token: &str) -> (ConnectionManager, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn".to_string(), + name: "conn".to_string(), + primary: EndpointConfig { + url: url.to_string(), + node: None, + token: Some(token.to_string()), + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + server_type: "pbs".to_string(), + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + (manager, dir) +} + +#[tokio::test] +async fn token_auth_sends_pbs_api_token_header() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/version") + .header("Authorization", format!("PBSAPIToken={}", TOKEN)); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"version":"3.2.3","release":"bookworm","repoid":"dd6b00e2"}}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await; + let version = manager + .pbs_get_version("conn") + .await + .expect("version should be fetched"); + assert_eq!(version.version, "3.2.3"); + assert_eq!(version.release, "bookworm"); + assert_eq!(version.repoid, "dd6b00e2"); + mock.assert(); +} + +#[tokio::test] +async fn password_mode_sends_pbs_auth_cookie() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/status/datastore-usage") + .header("Cookie", "PBSAuthCookie=ticket-123"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[]}"#); + }); + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn".to_string(), + name: "conn".to_string(), + primary: EndpointConfig { + url: server.base_url(), + node: None, + token: None, + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "password".to_string(), + username: Some("root@pam".to_string()), + nodes: vec![], + cluster_id: None, + server_type: "pbs".to_string(), + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + manager + .set_session_ticket("conn", "ticket-123", "csrf") + .await + .expect("session should be injected"); + + // The `/admin/datastore` merge call is best-effort; with no mock for it, + // httpmock answers 404 and the call degrades to the usage-only list. + let datastores = manager + .pbs_get_datastores("conn") + .await + .expect("datastores should be fetched"); + assert!(datastores.is_empty()); + mock.assert(); +} + +#[tokio::test] +async fn get_datastores_merges_usage_and_config() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api2/json/status/datastore-usage"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [{ + "store": "backup", + "backend-type": "filesystem", + "mount-status": "mounted", + "avail": 1_200_000_000_000u64, + "total": 2_000_000_000_000u64, + "used": 800_000_000_000u64, + "gc-status": { + "disk-bytes": 100, + "disk-chunks": 2, + "cache-stats": {"hits": 7, "misses": 3}, + "upid": "UPID:store:00000000:00000000:00000000:gc:backup::" + } + }] + }) + .to_string(), + ); + }); + server.mock(|when, then| { + when.method(GET).path("/api2/json/admin/datastore"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [{ + "store": "backup", + "comment": "Main store", + "maintenance": "offline" + }] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await; + let datastores = manager + .pbs_get_datastores("conn") + .await + .expect("datastores should be fetched"); + assert_eq!(datastores.len(), 1); + let datastore = &datastores[0]; + assert_eq!(datastore.store, "backup"); + assert_eq!(datastore.total, Some(2_000_000_000_000)); + assert_eq!(datastore.used, Some(800_000_000_000)); + assert_eq!(datastore.avail, Some(1_200_000_000_000)); + // Merged from /admin/datastore. + assert_eq!(datastore.comment.as_deref(), Some("Main store")); + assert_eq!(datastore.maintenance.as_deref(), Some("offline")); + // Usage-derived values survive where the config entry omits the key. + assert_eq!(datastore.backend_type.as_deref(), Some("filesystem")); + assert_eq!(datastore.mount_status.as_deref(), Some("mounted")); + // gc-status.cache-stats is hoisted onto gc_status.cache_hits/misses. + let gc = datastore.gc_status.as_ref().expect("gc status should be present"); + assert_eq!(gc.disk_bytes, Some(100)); + assert_eq!(gc.disk_chunks, Some(2)); + assert_eq!(gc.cache_hits, Some(7)); + assert_eq!(gc.cache_misses, Some(3)); + assert!(gc.upid.as_deref().unwrap_or("").starts_with("UPID:")); +} + +#[tokio::test] +async fn get_groups_hits_url_and_parses() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api2/json/admin/datastore/backup/groups"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [{ + "backup-id": "100", + "backup-type": "vm", + "backup-count": 3, + "last-backup": 1_700_000_000, + "comment": "Web server" + }] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await; + let groups = manager + .pbs_get_groups("conn", "backup") + .await + .expect("groups should be fetched"); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].backup_id, "100"); + assert_eq!(groups[0].backup_type, "vm"); + assert_eq!(groups[0].backup_count, Some(3)); + assert_eq!(groups[0].last_backup, Some(1_700_000_000)); + assert_eq!(groups[0].comment.as_deref(), Some("Web server")); + mock.assert(); +} + +#[tokio::test] +async fn get_snapshots_sends_backup_query_params() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/admin/datastore/backup/snapshots") + .query_param("backup-id", "100") + .query_param("backup-type", "vm"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [{ + "backup-id": "100", + "backup-type": "vm", + "backup-time": 1_700_000_000, + "size": 1_500_000_000, + "protected": true, + "comment": "Full backup", + "verification": {"state": "ok", "upid": "UPID:verify::"} + }] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await; + let snapshots = manager + .pbs_get_snapshots("conn", "backup", "100", "vm") + .await + .expect("snapshots should be fetched"); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].backup_id, "100"); + assert_eq!(snapshots[0].backup_time, 1_700_000_000); + assert_eq!(snapshots[0].size, Some(1_500_000_000)); + assert_eq!(snapshots[0].protected, Some(true)); + assert_eq!( + snapshots[0] + .verification + .as_ref() + .and_then(|verification| verification.state.as_deref()), + Some("ok") + ); + mock.assert(); +} + +#[tokio::test] +async fn delete_snapshot_uses_delete_verb_with_query_params() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/api2/json/admin/datastore/backup/snapshots") + .query_param("backup-id", "100") + .query_param("backup-type", "vm") + .query_param("backup-time", "1700000000"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await; + manager + .pbs_delete_snapshot("conn", "backup", "100", "vm", 1_700_000_000) + .await + .expect("snapshot should be deleted"); + mock.assert(); +} + +#[tokio::test] +async fn run_verify_posts_store_and_returns_upid() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/admin/datastore/backup/verify") + .body_includes("store=backup"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":"UPID:store:00000000:00000000:00000000:verify:backup::"}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await; + let upid = manager + .pbs_run_verify("conn", "backup") + .await + .expect("verify should start"); + assert!(upid.starts_with("UPID:")); + assert!(upid.contains("verify")); + mock.assert(); +} + +#[tokio::test] +async fn run_prune_sends_only_provided_keep_fields_and_dry_run() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/admin/datastore/backup/prune-datastore") + .body_includes("store=backup") + .body_includes("keep-last=7") + .body_includes("keep-daily=14") + .body_includes("dry-run=1") + .body_excludes("keep-weekly") + .body_excludes("keep-monthly") + .body_excludes("keep-yearly"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":"UPID:store:00000000:00000000:00000000:prune:backup::"}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await; + let upid = manager + .pbs_run_prune("conn", "backup", Some(7), Some(14), None, None, None, true) + .await + .expect("prune should start"); + assert!(upid.starts_with("UPID:")); + mock.assert_calls(1); +} + +#[tokio::test] +async fn download_snapshot_file_streams_binary_to_disk() { + let server = MockServer::start(); + let bytes: Vec = vec![0, 1, 2, 3, 250, 251, 252, 253, 254, 255]; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/admin/datastore/backup/download") + .query_param("backup-id", "100") + .query_param("backup-type", "vm") + .query_param("backup-time", "1700000000") + .query_param("file-name", "index.json.blob"); + then.status(200).body(&bytes[..]); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await; + let dir = tempfile::tempdir().expect("temp dir should be created"); + let save_path = dir.path().join("index.json.blob"); + let save_path_str = save_path.to_str().expect("path should be UTF-8"); + + let returned = manager + .pbs_download_snapshot_file( + "conn", + "backup", + "100", + "vm", + 1_700_000_000, + "index.json.blob", + false, + save_path_str, + ) + .await + .expect("download should succeed"); + assert_eq!(returned, save_path_str); + + let written = std::fs::read(&save_path).expect("downloaded file should exist"); + assert_eq!(written, bytes); + mock.assert(); +} + +#[tokio::test] +async fn get_tasks_maps_pbs_worker_fields_into_task() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api2/json/nodes/localhost/tasks"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + { + "upid": "UPID:localhost:00000000:00000000:00000000:verify:backup::root@pam:", + "node": "localhost", + "pid": 1234, + "pstart": 100, + "starttime": 1_700_000_000, + "endtime": 1_700_000_100, + "status": "ok", + "user": "root@pam", + "worker-id": "backup", + "worker-type": "verify" + }, + { + "upid": "UPID:localhost:00000000:00000000:00000000:gc:backup::root@pam:", + "node": "localhost", + "pid": 5678, + "pstart": 200, + "starttime": 1_700_000_000, + "status": "running", + "user": "root@pam", + "worker-type": "gc" + } + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await; + let tasks = manager + .pbs_get_tasks("conn") + .await + .expect("tasks should be fetched"); + assert_eq!(tasks.len(), 2); + + // A finished task maps `status: ok` onto the PVE-style exit status. + let finished = &tasks[0]; + assert_eq!(finished.r#type, "verify"); + assert_eq!(finished.id, "backup"); + assert_eq!(finished.node, "localhost"); + assert_eq!(finished.user, "root@pam"); + assert_eq!(finished.pid, 1234); + assert_eq!(finished.endtime, Some(1_700_000_100)); + assert_eq!(finished.status, None); + assert_eq!(finished.exitstatus.as_deref(), Some("OK")); + + // A running task keeps `status: running` and has no exit status. + let running = &tasks[1]; + assert_eq!(running.r#type, "gc"); + assert_eq!(running.status.as_deref(), Some("running")); + assert_eq!(running.exitstatus, None); + mock.assert(); +} + +#[tokio::test] +async fn connect_for_pbs_skips_node_discovery() { + let server = MockServer::start(); + let version_mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/version") + .header("Authorization", format!("PBSAPIToken={}", TOKEN)); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"version":"3.2.3","release":"bookworm","repoid":"dd6b00e2"}}"#); + }); + // No `/nodes` or `/cluster/status` mocks are registered: a PVE-style + // discovery pass would hit httpmock's 404 and fail the connect. + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn".to_string(), + name: "conn".to_string(), + primary: EndpointConfig { + url: server.base_url(), + node: None, + token: Some(TOKEN.to_string()), + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + server_type: "pbs".to_string(), + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + + let result = manager + .connect("conn", &path) + .await + .expect("PBS connect should succeed with only /version mocked"); + assert_eq!(result.status, "connected"); + assert_eq!(result.merged_into, None); + assert_eq!(result.connection_id, "conn"); + version_mock.assert_calls(1); +} + +#[tokio::test] +async fn login_with_token_validates_against_pbs_version_endpoint() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/version") + .header("Authorization", format!("PBSAPIToken={}", TOKEN)); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"version":"3.2.3","release":"bookworm","repoid":"dd6b00e2"}}"#); + }); + + let manager = ConnectionManager::new(); + let result = manager + .login_with_token(&server.base_url(), TOKEN, "pbs") + .await + .expect("login should validate the token against /version"); + assert!(!result.connection_id.is_empty()); + assert_eq!(result.ticket, TOKEN); + mock.assert(); +} + +#[test] +fn public_structs_serialize_to_camel_case_shapes() { + // The frontend types (`src/types/pbs.ts`) mirror the backend structs' + // camelCase serialization; these assertions guard that contract for every + // struct that deserializes from kebab-case raw JSON. + let snapshot = clustri::PbsSnapshot { + backup_id: "100".to_string(), + backup_type: "vm".to_string(), + backup_time: 1_700_000_000, + size: Some(1_500_000_000), + protected: Some(true), + comment: None, + files: Some(vec!["index.json.blob".to_string()]), + fingerprint: None, + owner: None, + verification: None, + }; + let json = serde_json::to_value(&snapshot).expect("snapshot should serialize"); + assert_eq!(json["backupId"], "100"); + assert_eq!(json["backupType"], "vm"); + assert_eq!(json["backupTime"], 1_700_000_000); + assert_eq!(json["size"], 1_500_000_000); + assert_eq!(json["protected"], true); + assert_eq!(json["files"][0], "index.json.blob"); + + let job = clustri::PbsJob { + id: "verify-1".to_string(), + store: Some("backup".to_string()), + schedule: None, + comment: None, + disable: None, + last_run_state: Some("OK".to_string()), + last_run_endtime: Some(1_700_000_000), + next_run: None, + keep_last: Some(7), + keep_daily: None, + keep_weekly: None, + keep_monthly: None, + keep_yearly: None, + ignore_verified: None, + max_depth: Some(5), + }; + let json = serde_json::to_value(&job).expect("job should serialize"); + assert_eq!(json["lastRunState"], "OK"); + assert_eq!(json["lastRunEndtime"], 1_700_000_000); + assert_eq!(json["keepLast"], 7); + assert_eq!(json["maxDepth"], 5); + + // Deserialize from the raw kebab-case wire shape, then verify the + // serialized shape is camelCase (the same round-trip the API requests go + // through). + let node_status: clustri::PbsNodeStatus = serde_json::from_value(serde_json::json!({ + "cpu": 0.15, + "current-kernel": {"machine": "x86_64", "release": "6.8.12-4-pve", "sysname": "Linux"} + })) + .expect("node status should deserialize from kebab-case keys"); + let json = serde_json::to_value(&node_status).expect("node status should serialize"); + assert_eq!(json["cpu"], 0.15); + assert_eq!(json["currentKernel"]["release"], "6.8.12-4-pve"); +} diff --git a/src-tauri/tests/api_snapshots_backups.rs b/src-tauri/tests/api_snapshots_backups.rs index 6864707..7fd9ab7 100644 --- a/src-tauri/tests/api_snapshots_backups.rs +++ b/src-tauri/tests/api_snapshots_backups.rs @@ -41,6 +41,7 @@ async fn setup_manager( username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) diff --git a/src-tauri/tests/cluster_flow.rs b/src-tauri/tests/cluster_flow.rs index 84d4dab..fb95298 100644 --- a/src-tauri/tests/cluster_flow.rs +++ b/src-tauri/tests/cluster_flow.rs @@ -124,6 +124,7 @@ fn token_config(id: &str, url: &str, fallbacks: Vec) -> Connecti username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), } } diff --git a/src-tauri/tests/discovery.rs b/src-tauri/tests/discovery.rs index ab66a1f..ffdff75 100644 --- a/src-tauri/tests/discovery.rs +++ b/src-tauri/tests/discovery.rs @@ -59,6 +59,7 @@ async fn setup_manager( username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) diff --git a/src-tauri/tests/failover.rs b/src-tauri/tests/failover.rs index 11b529b..4d411cc 100644 --- a/src-tauri/tests/failover.rs +++ b/src-tauri/tests/failover.rs @@ -10,6 +10,7 @@ use httpmock::prelude::*; use httpmock::Mock; use clustri::{ api_request, AuthContext, AuthMode, ConnectionConfig, ConnectionManager, EndpointConfig, Error, + ServerType, }; use reqwest::Client; use reqwest::Method as RMethod; @@ -32,6 +33,7 @@ fn token_auth() -> AuthContext { token: Some(TOKEN.to_string()), ticket: None, csrf_token: None, + server_type: ServerType::Pve, } } @@ -70,6 +72,7 @@ async fn setup_manager_with_fallbacks( username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) diff --git a/src-tauri/tests/live.rs b/src-tauri/tests/live.rs index 5989657..3bfb4c8 100644 --- a/src-tauri/tests/live.rs +++ b/src-tauri/tests/live.rs @@ -25,7 +25,7 @@ use std::time::Duration; use clustri::{ api_request, derive_node_url, AuthContext, AuthMode, ConnectionConfig, ConnectionManager, - EndpointConfig, + EndpointConfig, ServerType, }; use reqwest::Method; @@ -136,6 +136,7 @@ async fn manager_with_session( username: Some(user.to_string()), nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) @@ -155,6 +156,7 @@ fn ticket_auth(ticket: &str) -> AuthContext { token: None, ticket: Some(ticket.to_string()), csrf_token: None, + server_type: ServerType::Pve, } } @@ -909,6 +911,7 @@ async fn live_failover_to_fallback() { username: Some(user.to_string()), nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) @@ -993,6 +996,7 @@ async fn live_cluster_identity_consistent_across_nodes() { username: Some(user.to_string()), nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) diff --git a/src-tauri/tests/persistence.rs b/src-tauri/tests/persistence.rs index acaa381..bd893e5 100644 --- a/src-tauri/tests/persistence.rs +++ b/src-tauri/tests/persistence.rs @@ -29,6 +29,7 @@ fn token_config(id: &str, url: &str, token: &str, accept_untrusted: bool) -> Con username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), } } @@ -61,6 +62,7 @@ async fn persisted_configs_round_trip_without_secrets() { username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .add_connection(config, &path) @@ -216,6 +218,7 @@ async fn update_connection_preserves_cert_settings_and_replaces_config() { username: None, nodes: vec![], cluster_id: None, + server_type: "pve".to_string(), }; manager .update_connection(updated, &path) diff --git a/src/App.tsx b/src/App.tsx index b703b58..03f1813 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,9 @@ import { BackupList } from '@/components/backups/BackupList' import { CommandPalette } from '@/components/command/CommandPalette' import { StorageOverview } from '@/components/storage/StorageOverview' import { StorageDetail } from '@/components/storage/StorageDetail' +import { PbsOverview } from '@/components/pbs/PbsOverview' +import { PbsDatastores } from '@/components/pbs/PbsDatastores' +import { PbsDatastoreDetail } from '@/components/pbs/PbsDatastoreDetail' import { SettingsPage } from '@/components/settings/SettingsPage' import { ErrorBoundary } from '@/components/ErrorBoundary' import { ToastProvider, useToast } from '@/components/ui/toast' @@ -43,6 +46,9 @@ type View = | { type: 'backups' } | { type: 'storage' } | { type: 'storage-detail'; storage: string; node: string } + | { type: 'pbs-overview' } + | { type: 'pbs-datastores' } + | { type: 'pbs-datastore-detail'; store: string } | { type: 'settings' } function AppContent() { @@ -317,6 +323,23 @@ function AppContent() { onBack={() => handleNavigate({ type: 'storage' })} /> ) + case 'pbs-overview': + return + case 'pbs-datastores': + return ( + handleNavigate({ type: 'pbs-datastore-detail', store })} + /> + ) + case 'pbs-datastore-detail': + return ( + handleNavigate({ type: 'pbs-datastores' })} + /> + ) default: return (
@@ -328,6 +351,28 @@ function AppContent() { const activeConnection = connections.find((c) => c.id === activeConnectionId) + // When the active connection switches to a PBS server, leave any VE-only + // view behind and land on the PBS overview. Shared views (tasks, settings) + // and PBS views are left untouched. + const activeServerType = activeConnection?.serverType ?? 'pve' + useEffect(() => { + if (activeServerType !== 'pbs') return + const veOnlyViews = new Set([ + 'dashboard', + 'vms', + 'vm-detail', + 'nodes', + 'node-detail', + 'containers', + 'backups', + 'storage', + 'storage-detail', + ]) + if (veOnlyViews.has(view.type)) { + setView({ type: 'pbs-overview' }) + } + }, [activeConnectionId, activeServerType, view.type]) + return (
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && ( diff --git a/src/components/command/CommandPalette.tsx b/src/components/command/CommandPalette.tsx index 000a27b..71aff8b 100644 --- a/src/components/command/CommandPalette.tsx +++ b/src/components/command/CommandPalette.tsx @@ -40,6 +40,9 @@ type View = | { type: 'backups' } | { type: 'storage' } | { type: 'storage-detail'; storage: string; node: string } + | { type: 'pbs-overview' } + | { type: 'pbs-datastores' } + | { type: 'pbs-datastore-detail'; store: string } | { type: 'settings' } type CommandCategory = 'recent' | 'vms' | 'actions' | 'navigation' | 'connections' @@ -194,8 +197,9 @@ export function CommandPalette({ const activeConnectionId = useConnectionStore((s) => s.activeConnectionId) const connections = useConnectionStore((s) => s.connections) const setActiveConnection = useConnectionStore((s) => s.setActiveConnection) + const serverType = connections.find((c) => c.id === activeConnectionId)?.serverType ?? 'pve' - const { data: vms = [] } = useVMs(activeConnectionId) + const { data: vms = [] } = useVMs(serverType === 'pbs' ? null : activeConnectionId) // VM mutation hooks const startVM = useStartVM() @@ -210,128 +214,179 @@ export function CommandPalette({ const items: CommandItem[] = [] // -- Navigation -- - items.push( - { - id: 'nav-dashboard', - label: 'Go to Dashboard', - icon: LayoutDashboard, - category: 'navigation', - shortcut: '⌘1', - keywords: ['dashboard', 'home', 'overview'], - onExecute: () => onNavigate({ type: 'dashboard' }), - }, - { - id: 'nav-vms', - label: 'Go to VMs', - icon: Box, - category: 'navigation', - shortcut: '⌘2', - keywords: ['vm', 'vms', 'virtual machines', 'containers'], - onExecute: () => onNavigate({ type: 'vms' }), - }, - { - id: 'nav-tasks', - label: 'Go to Tasks', - icon: ListTodo, - category: 'navigation', - shortcut: '⌘3', - keywords: ['tasks', 'jobs', 'queue'], - onExecute: () => onNavigate({ type: 'tasks' }), - }, - { - id: 'nav-backups', - label: 'Go to Backups', - icon: Shield, - category: 'navigation', - shortcut: '⌘4', - keywords: ['backups', 'restore', 'backup'], - onExecute: () => onNavigate({ type: 'backups' }), - }, - { - id: 'nav-storage', - label: 'Go to Storage', - icon: HardDrive, - category: 'navigation', - shortcut: '⌘5', - keywords: ['storage', 'disks', 'volumes'], - onExecute: () => onNavigate({ type: 'storage' }), - }, - { - id: 'nav-settings', - label: 'Go to Settings', - icon: Settings, - category: 'navigation', - shortcut: '⌘6', - keywords: ['settings', 'preferences', 'configuration'], - onExecute: () => onNavigate({ type: 'settings' }), - }, - { - id: 'nav-add-connection', - label: 'Add Connection', - icon: Plus, - category: 'navigation', - keywords: ['add', 'connection', 'server', 'proxmox', 'new'], - onExecute: () => onAddConnection(), - }, - ) - - // -- VMs -- - for (const vm of vms) { - items.push({ - id: `vm-detail-${vm.vmid}`, - label: vm.name, - description: `${vm.type.toUpperCase()} · VMID ${vm.vmid} · ${vm.node} · ${vm.status}`, - icon: Server, - category: 'vms', - keywords: [vm.name, String(vm.vmid), vm.node, vm.type, vm.status], - onExecute: () => onNavigate({ type: 'vm-detail', vm }), - }) + if (serverType === 'pbs') { + items.push( + { + id: 'nav-pbs-overview', + label: 'Go to PBS Overview', + icon: LayoutDashboard, + category: 'navigation', + shortcut: '⌘1', + keywords: ['overview', 'pbs', 'backup server', 'dashboard', 'home'], + onExecute: () => onNavigate({ type: 'pbs-overview' }), + }, + { + id: 'nav-pbs-datastores', + label: 'Go to Datastores', + icon: HardDrive, + category: 'navigation', + shortcut: '⌘2', + keywords: ['datastores', 'datastore', 'storage', 'backups'], + onExecute: () => onNavigate({ type: 'pbs-datastores' }), + }, + { + id: 'nav-tasks', + label: 'Go to Tasks', + icon: ListTodo, + category: 'navigation', + shortcut: '⌘3', + keywords: ['tasks', 'jobs', 'queue'], + onExecute: () => onNavigate({ type: 'tasks' }), + }, + { + id: 'nav-settings', + label: 'Go to Settings', + icon: Settings, + category: 'navigation', + shortcut: '⌘6', + keywords: ['settings', 'preferences', 'configuration'], + onExecute: () => onNavigate({ type: 'settings' }), + }, + { + id: 'nav-add-connection', + label: 'Add Connection', + icon: Plus, + category: 'navigation', + keywords: ['add', 'connection', 'server', 'proxmox', 'pbs', 'new'], + onExecute: () => onAddConnection(), + }, + ) + } else { + items.push( + { + id: 'nav-dashboard', + label: 'Go to Dashboard', + icon: LayoutDashboard, + category: 'navigation', + shortcut: '⌘1', + keywords: ['dashboard', 'home', 'overview'], + onExecute: () => onNavigate({ type: 'dashboard' }), + }, + { + id: 'nav-vms', + label: 'Go to VMs', + icon: Box, + category: 'navigation', + shortcut: '⌘2', + keywords: ['vm', 'vms', 'virtual machines', 'containers'], + onExecute: () => onNavigate({ type: 'vms' }), + }, + { + id: 'nav-tasks', + label: 'Go to Tasks', + icon: ListTodo, + category: 'navigation', + shortcut: '⌘3', + keywords: ['tasks', 'jobs', 'queue'], + onExecute: () => onNavigate({ type: 'tasks' }), + }, + { + id: 'nav-backups', + label: 'Go to Backups', + icon: Shield, + category: 'navigation', + shortcut: '⌘4', + keywords: ['backups', 'restore', 'backup'], + onExecute: () => onNavigate({ type: 'backups' }), + }, + { + id: 'nav-storage', + label: 'Go to Storage', + icon: HardDrive, + category: 'navigation', + shortcut: '⌘5', + keywords: ['storage', 'disks', 'volumes'], + onExecute: () => onNavigate({ type: 'storage' }), + }, + { + id: 'nav-settings', + label: 'Go to Settings', + icon: Settings, + category: 'navigation', + shortcut: '⌘6', + keywords: ['settings', 'preferences', 'configuration'], + onExecute: () => onNavigate({ type: 'settings' }), + }, + { + id: 'nav-add-connection', + label: 'Add Connection', + icon: Plus, + category: 'navigation', + keywords: ['add', 'connection', 'server', 'proxmox', 'new'], + onExecute: () => onAddConnection(), + }, + ) } - // -- VM Actions -- - for (const vm of vms) { - if (vm.status === 'running') { - items.push( - { - id: `action-stop-${vm.vmid}`, - label: `Stop ${vm.name}`, - description: `Force stop ${vm.type.toUpperCase()} VMID ${vm.vmid}`, - icon: Square, - category: 'actions', - keywords: ['stop', 'halt', 'power off', vm.name, String(vm.vmid)], - onExecute: () => stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }), - }, - { - id: `action-shutdown-${vm.vmid}`, - label: `Shutdown ${vm.name}`, - description: `Gracefully shutdown ${vm.type.toUpperCase()} VMID ${vm.vmid}`, - icon: Power, - category: 'actions', - keywords: ['shutdown', 'graceful', 'power', vm.name, String(vm.vmid)], - onExecute: () => shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }), - }, - { - id: `action-reboot-${vm.vmid}`, - label: `Reboot ${vm.name}`, - description: `Reboot ${vm.type.toUpperCase()} VMID ${vm.vmid}`, - icon: RotateCw, - category: 'actions', - keywords: ['reboot', 'restart', vm.name, String(vm.vmid)], - onExecute: () => rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }), - }, - ) - } - if (vm.status === 'stopped' || vm.status === 'paused' || vm.status === 'suspended') { + // -- VMs (not applicable to PBS servers) -- + if (serverType !== 'pbs') { + for (const vm of vms) { items.push({ - id: `action-start-${vm.vmid}`, - label: `Start ${vm.name}`, - description: `Start ${vm.type.toUpperCase()} VMID ${vm.vmid}`, - icon: Play, - category: 'actions', - keywords: ['start', 'boot', 'power on', vm.name, String(vm.vmid)], - onExecute: () => startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }), + id: `vm-detail-${vm.vmid}`, + label: vm.name, + description: `${vm.type.toUpperCase()} · VMID ${vm.vmid} · ${vm.node} · ${vm.status}`, + icon: Server, + category: 'vms', + keywords: [vm.name, String(vm.vmid), vm.node, vm.type, vm.status], + onExecute: () => onNavigate({ type: 'vm-detail', vm }), }) } + + // -- VM Actions -- + for (const vm of vms) { + if (vm.status === 'running') { + items.push( + { + id: `action-stop-${vm.vmid}`, + label: `Stop ${vm.name}`, + description: `Force stop ${vm.type.toUpperCase()} VMID ${vm.vmid}`, + icon: Square, + category: 'actions', + keywords: ['stop', 'halt', 'power off', vm.name, String(vm.vmid)], + onExecute: () => stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }), + }, + { + id: `action-shutdown-${vm.vmid}`, + label: `Shutdown ${vm.name}`, + description: `Gracefully shutdown ${vm.type.toUpperCase()} VMID ${vm.vmid}`, + icon: Power, + category: 'actions', + keywords: ['shutdown', 'graceful', 'power', vm.name, String(vm.vmid)], + onExecute: () => shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }), + }, + { + id: `action-reboot-${vm.vmid}`, + label: `Reboot ${vm.name}`, + description: `Reboot ${vm.type.toUpperCase()} VMID ${vm.vmid}`, + icon: RotateCw, + category: 'actions', + keywords: ['reboot', 'restart', vm.name, String(vm.vmid)], + onExecute: () => rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }), + }, + ) + } + if (vm.status === 'stopped' || vm.status === 'paused' || vm.status === 'suspended') { + items.push({ + id: `action-start-${vm.vmid}`, + label: `Start ${vm.name}`, + description: `Start ${vm.type.toUpperCase()} VMID ${vm.vmid}`, + icon: Play, + category: 'actions', + keywords: ['start', 'boot', 'power on', vm.name, String(vm.vmid)], + onExecute: () => startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }), + }) + } + } } // -- Connections -- @@ -351,6 +406,7 @@ export function CommandPalette({ }, [ vms, connections, + serverType, onNavigate, onAddConnection, startVM, diff --git a/src/components/connections/ConnectionDialog.tsx b/src/components/connections/ConnectionDialog.tsx index ca80b68..318b55a 100644 --- a/src/components/connections/ConnectionDialog.tsx +++ b/src/components/connections/ConnectionDialog.tsx @@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ShieldAlert, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' import { useConnectionStore } from '@/stores/connectionStore' import { useToast } from '@/components/ui/toast' import { @@ -30,6 +31,7 @@ import type { AuthMode, CertificateInfo, LoginResult, + ServerType, } from '@/types/connection' interface ConnectionDialogProps { @@ -51,6 +53,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial const [step, setStep] = useState('credentials') const [authMode, setAuthMode] = useState('password') + const [serverType, setServerType] = useState('pve') const [name, setName] = useState('') const [url, setUrl] = useState('') const [username, setUsername] = useState('') @@ -63,6 +66,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial const resetForm = () => { setStep('credentials') + setServerType('pve') setName('') setUrl('') setUsername('') @@ -130,7 +134,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial await loginWithPassword(cleanUrl, username, password) } } else if (apiToken) { - await loginWithToken(cleanUrl, apiToken) + await loginWithToken(cleanUrl, apiToken, editing.serverType ?? 'pve') } const config: ConnectionConfig = { @@ -148,6 +152,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial isCluster: editing.isCluster, authMode, username: authMode === 'password' ? username : undefined, + serverType: editing.serverType ?? 'pve', nodes: editing.nodes, clusterId: editing.clusterId, } @@ -191,7 +196,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial if (!apiToken) { throw new Error('API token is required') } - result = await loginWithToken(cleanUrl, apiToken) + result = await loginWithToken(cleanUrl, apiToken, serverType) } if (!isTauri()) { @@ -211,6 +216,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial isCluster: false, authMode, username: authMode === 'password' ? username : undefined, + serverType, } await addConnection(config) @@ -260,6 +266,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial isCluster: false, authMode, username: authMode === 'password' ? username : undefined, + serverType, } // The backend requires the connection to exist before pinning its @@ -316,14 +323,58 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial {step === 'credentials' && ( <> - {editing ? 'Edit Connection' : 'Connect to Proxmox'} + + {editing + ? 'Edit Connection' + : serverType === 'pbs' + ? 'Connect to Proxmox Backup Server' + : 'Connect to Proxmox'} + {editing ? 'Update this connection. The server URL cannot be changed; add a new connection to target a different server.' - : 'Sign in with your Proxmox credentials or API token'} + : serverType === 'pbs' + ? 'Sign in with your Proxmox Backup Server credentials or API token' + : 'Sign in with your Proxmox credentials or API token'} + {!editing && ( +
+ +
+ + +
+
+ )} + { setAuthMode(v as AuthMode); setError(null) }}> Username & Password @@ -335,7 +386,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial setUrl(e.target.value)} required @@ -345,7 +396,9 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial

{editing ? 'Server URL cannot be changed while editing' - : 'The URL of your Proxmox server (must use HTTPS)'} + : serverType === 'pbs' + ? 'The URL of your Proxmox Backup Server (must use HTTPS)' + : 'The URL of your Proxmox server (must use HTTPS)'}

@@ -447,7 +500,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial

This is a self-signed or untrusted certificate. Verify the fingerprint - against your Proxmox server's SSL certificate before trusting. + against your server's SSL certificate before trusting.

diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 869e228..c9001d0 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button' import { Plus, Server, LayoutDashboard, HardDrive, Box, ListTodo, Shield, Settings, Hexagon } from 'lucide-react' import { cn } from '@/lib/utils' -type ViewType = 'dashboard' | 'vms' | 'vm-detail' | 'nodes' | 'node-detail' | 'containers' | 'tasks' | 'backups' | 'storage' | 'storage-detail' | 'settings' +type ViewType = 'dashboard' | 'vms' | 'vm-detail' | 'nodes' | 'node-detail' | 'containers' | 'tasks' | 'backups' | 'storage' | 'storage-detail' | 'settings' | 'pbs-overview' | 'pbs-datastores' | 'pbs-datastore-detail' type NavigationTarget = | { type: ViewType } @@ -77,34 +77,54 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp

Offline

)}
- onNavigate?.({ type: 'dashboard' })} - /> - onNavigate?.({ type: 'nodes' })} - /> - onNavigate?.({ type: 'vms' })} - /> - onNavigate?.({ type: 'containers' })} - /> - onNavigate?.({ type: 'storage' })} /> - onNavigate?.({ type: 'tasks' })} /> - onNavigate?.({ type: 'backups' })} /> - {connection.nodes && connection.nodes.length > 0 && ( + {connection.serverType === 'pbs' ? ( + <> + onNavigate?.({ type: 'pbs-overview' })} + /> + onNavigate?.({ type: 'pbs-datastores' })} + /> + onNavigate?.({ type: 'tasks' })} /> + + ) : ( + <> + onNavigate?.({ type: 'dashboard' })} + /> + onNavigate?.({ type: 'nodes' })} + /> + onNavigate?.({ type: 'vms' })} + /> + onNavigate?.({ type: 'containers' })} + /> + onNavigate?.({ type: 'storage' })} /> + onNavigate?.({ type: 'tasks' })} /> + onNavigate?.({ type: 'backups' })} /> + + )} + {connection.serverType !== 'pbs' && connection.nodes && connection.nodes.length > 0 && ( <>

Cluster nodes diff --git a/src/components/pbs/PbsDatastoreDetail.tsx b/src/components/pbs/PbsDatastoreDetail.tsx new file mode 100644 index 0000000..c553a03 --- /dev/null +++ b/src/components/pbs/PbsDatastoreDetail.tsx @@ -0,0 +1,567 @@ +import { useCallback, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { EmptyState } from '@/components/ui/empty-state' +import { Skeleton } from '@/components/ui/skeleton' +import { + ArrowLeft, + CheckCircle, + Clock, + Download, + Eraser, + HardDrive, + Recycle, + RefreshCw, + ShieldCheck, + Trash, + XCircle, +} from 'lucide-react' +import { + usePbsDatastores, + usePbsGroups, + usePbsSnapshots, + usePbsVerifyJobs, + usePbsPruneJobs, + usePbsGcJobs, + usePbsDeleteSnapshot, + usePbsDeleteGroup, + queryKeys, +} from '@/hooks/usePbs' +import { VerifyDialog } from '@/components/pbs/dialogs/VerifyDialog' +import { PruneDialog } from '@/components/pbs/dialogs/PruneDialog' +import { GcDialog } from '@/components/pbs/dialogs/GcDialog' +import { DownloadFilesDialog } from '@/components/pbs/dialogs/DownloadFilesDialog' +import { formatBytes } from '@/lib/format' +import { cn } from '@/lib/utils' +import type { LucideIcon } from 'lucide-react' +import type { PbsBackupGroup, PbsJob, PbsSnapshot } from '@/types/pbs' + +interface PbsDatastoreDetailProps { + connectionId: string + store: string + onBack: () => void +} + +function formatTimestamp(seconds?: number): string { + if (!seconds) return 'N/A' + return new Date(seconds * 1000).toLocaleString() +} + +function getUsageColor(percent: number): string { + if (percent >= 90) return 'bg-destructive' + if (percent >= 70) return 'bg-warning' + return 'bg-success' +} + +function LastRunStateBadge({ state }: { state?: string }) { + if (!state) { + return + } + const ok = state.toUpperCase() === 'OK' + return ( + + {ok ? : } + {state} + + ) +} + +function JobTable({ + title, + icon: Icon, + jobs, + showKeep, +}: { + title: string + icon: LucideIcon + jobs?: PbsJob[] + showKeep?: boolean +}) { + return ( +

+

{title}

+ + + {!jobs || jobs.length === 0 ? ( + + ) : ( +
+ + + + + + + + + {showKeep && ( + + )} + + + + {jobs.map((job) => ( + + + + + + + {showKeep && ( + + )} + + ))} + +
IDStoreScheduleLast RunNext RunRetention
{job.id}{job.store ?? '—'} +
+ + {job.schedule ?? '—'} +
+
+ + + {formatTimestamp(job.nextRun)} + + {[ + job.keepLast != null && `last ${job.keepLast}`, + job.keepDaily != null && `daily ${job.keepDaily}`, + job.keepWeekly != null && `weekly ${job.keepWeekly}`, + job.keepMonthly != null && `monthly ${job.keepMonthly}`, + job.keepYearly != null && `yearly ${job.keepYearly}`, + ] + .filter((part): part is string => !!part) + .join(' · ') || '—'} +
+
+ )} +
+
+
+ ) +} + +export function PbsDatastoreDetail({ connectionId, store, onBack }: PbsDatastoreDetailProps) { + const queryClient = useQueryClient() + const [selectedGroup, setSelectedGroup] = useState(null) + const [verifyOpen, setVerifyOpen] = useState(false) + const [pruneOpen, setPruneOpen] = useState(false) + const [gcOpen, setGcOpen] = useState(false) + const [downloadSnapshot, setDownloadSnapshot] = useState(null) + const [confirmDeleteSnapshot, setConfirmDeleteSnapshot] = useState(null) + const [confirmDeleteGroup, setConfirmDeleteGroup] = useState(false) + + const { data: datastores } = usePbsDatastores(connectionId) + const datastore = datastores?.find((d) => d.store === store) + + const { data: groups, isLoading: groupsLoading, error: groupsError } = usePbsGroups(connectionId, store) + const { + data: snapshots, + isLoading: snapshotsLoading, + } = usePbsSnapshots( + connectionId, + store, + selectedGroup?.backupId ?? null, + selectedGroup?.backupType ?? null, + ) + const { data: verifyJobs } = usePbsVerifyJobs(connectionId) + const { data: pruneJobs } = usePbsPruneJobs(connectionId) + const { data: gcJobs } = usePbsGcJobs(connectionId) + + const deleteSnapshot = usePbsDeleteSnapshot() + const deleteGroup = usePbsDeleteGroup() + + const handleRefresh = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connectionId) }) + queryClient.invalidateQueries({ queryKey: queryKeys.pbsGroups(connectionId, store) }) + if (selectedGroup) { + queryClient.invalidateQueries({ + queryKey: queryKeys.pbsSnapshots( + connectionId, + store, + selectedGroup.backupId, + selectedGroup.backupType, + ), + }) + } + queryClient.invalidateQueries({ queryKey: queryKeys.pbsVerifyJobs(connectionId) }) + queryClient.invalidateQueries({ queryKey: queryKeys.pbsPruneJobs(connectionId) }) + queryClient.invalidateQueries({ queryKey: queryKeys.pbsGcJobs(connectionId) }) + }, [queryClient, connectionId, store, selectedGroup]) + + if (groupsLoading) { + return ( +
+
+
+ +
+ + +
+
+ + + +
+
+ ) + } + + if (groupsError) { + return ( +
+

Failed to load backup groups

+
+ ) + } + + const percent = datastore?.total && datastore.total > 0 + ? ((datastore.used ?? 0) / datastore.total) * 100 + : 0 + const usageColor = getUsageColor(percent) + const hasError = !!datastore?.error + + return ( +
+
+ {/* Header */} +
+ +
+
+ +

{store}

+ {hasError && ( + + + Error + + )} + {datastore?.maintenance && ( + + Maintenance + + )} +
+

Datastore overview

+
+ +
+ + {/* Datastore usage + actions */} + + + Usage +
+ + + +
+
+ + {hasError && ( +

{datastore.error}

+ )} +
+
+ Disk Usage + {percent.toFixed(1)}% +
+
+
+
+
+ {formatBytes(datastore?.used ?? 0)} used + {formatBytes(datastore?.total ?? 0)} total +
+
+
+ {formatBytes(datastore?.avail ?? 0)} available +
+ + + + {/* Groups / Snapshots drill-down */} + {selectedGroup ? ( + <> +
+ +
+

+ + {selectedGroup.backupType} + {' '} + {selectedGroup.backupId} +

+ {selectedGroup.comment && ( +

{selectedGroup.comment}

+ )} +
+ +
+ + + + {snapshotsLoading ? ( +
+ + +
+ ) : !snapshots || snapshots.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {snapshots.map((snapshot) => ( + + + + + + + + ))} + +
Backup TimeSizeProtectedVerificationActions
+ {formatTimestamp(snapshot.backupTime)} + + {formatBytes(snapshot.size ?? 0)} + + {snapshot.protected ? ( + + Protected + + ) : ( + + )} + + {snapshot.verification?.state ? ( + snapshot.verification.state === 'ok' ? ( + + + Verified + + ) : ( + + + Failed + + ) + ) : ( + + )} + +
+ + +
+
+
+ )} +
+
+ + ) : ( + <> +
+

Backup Groups

+ {groups?.length ?? 0} groups +
+ + + {!groups || groups.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {groups.map((group) => ( + setSelectedGroup(group)} + > + + + + + + + ))} + +
TypeBackup IDBackupsLast BackupComment
+ + {group.backupType} + + {group.backupId} + {group.backupCount ?? '—'} + + {formatTimestamp(group.lastBackup)} + {group.comment ?? '—'}
+
+ )} +
+
+ + )} + + {/* Job lists */} + + + +
+ + {/* Dialogs */} + + + + + {downloadSnapshot && ( + { + if (!open) setDownloadSnapshot(null) + }} + store={store} + backupId={downloadSnapshot.backupId} + backupType={downloadSnapshot.backupType} + backupTime={downloadSnapshot.backupTime} + /> + )} + + { + if (!open) setConfirmDeleteSnapshot(null) + }} + title="Delete Snapshot" + description={ + confirmDeleteSnapshot + ? `Are you sure you want to delete snapshot ${confirmDeleteSnapshot.backupType}/${confirmDeleteSnapshot.backupId}@${confirmDeleteSnapshot.backupTime}? This action cannot be undone.` + : undefined + } + confirmLabel="Delete" + isLoading={deleteSnapshot.isPending} + onConfirm={() => { + if (confirmDeleteSnapshot) { + deleteSnapshot.mutate( + { + store, + backupId: confirmDeleteSnapshot.backupId, + backupType: confirmDeleteSnapshot.backupType, + backupTime: confirmDeleteSnapshot.backupTime, + }, + { onSettled: () => setConfirmDeleteSnapshot(null) }, + ) + } + }} + /> + + { + if (selectedGroup) { + deleteGroup.mutate( + { + store, + backupId: selectedGroup.backupId, + backupType: selectedGroup.backupType, + }, + { + onSettled: () => { + setConfirmDeleteGroup(false) + setSelectedGroup(null) + }, + }, + ) + } + }} + /> +
+ ) +} diff --git a/src/components/pbs/PbsDatastores.tsx b/src/components/pbs/PbsDatastores.tsx new file mode 100644 index 0000000..f5b69c7 --- /dev/null +++ b/src/components/pbs/PbsDatastores.tsx @@ -0,0 +1,166 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { EmptyState } from '@/components/ui/empty-state' +import { PageSkeleton, Skeleton } from '@/components/ui/skeleton' +import { HardDrive, AlertTriangle, XCircle } from 'lucide-react' +import { usePbsDatastores } from '@/hooks/usePbs' +import { formatBytes } from '@/lib/format' +import { cn } from '@/lib/utils' +import type { PbsDatastore } from '@/types/pbs' + +interface PbsDatastoresProps { + connectionId: string + onDatastoreClick?: (store: string) => void +} + +function getUsageColor(percent: number): string { + if (percent >= 90) return 'bg-destructive' + if (percent >= 70) return 'bg-warning' + return 'bg-success' +} + +function PbsDatastoreCard({ + datastore, + onClick, +}: { + datastore: PbsDatastore + onClick: () => void +}) { + const percent = datastore.total && datastore.total > 0 + ? ((datastore.used ?? 0) / datastore.total) * 100 + : 0 + const usageColor = getUsageColor(percent) + const hasError = !!datastore.error + + return ( + + + + + {datastore.store} + +
+ {datastore.backendType && ( + + {datastore.backendType} + + )} + {datastore.mountStatus && ( + {datastore.mountStatus} + )} +
+
+ + {datastore.comment && ( +

{datastore.comment}

+ )} + + {/* Usage Bar */} +
+
+ Usage + {percent.toFixed(1)}% +
+
+
+
+
+ + {/* Size Info */} +
+ {formatBytes(datastore.used ?? 0)} used + {formatBytes(datastore.total ?? 0)} total +
+
+ {formatBytes(datastore.avail ?? 0)} available +
+ + {/* Status badges */} + {(hasError || datastore.maintenance) && ( +
+ {hasError && ( + + + Error + + )} + {datastore.maintenance && ( + + + Maintenance + + )} +
+ )} + + + ) +} + +export function PbsDatastores({ connectionId, onDatastoreClick }: PbsDatastoresProps) { + const { data: datastores, isLoading, error } = usePbsDatastores(connectionId) + + if (isLoading) { + return ( + +
+ {Array.from({ length: 6 }, (_, i) => ( + + ))} +
+
+ ) + } + + if (error) { + return ( +
+

Failed to load datastores

+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+ +

Datastores

+
+

+ {datastores?.length ?? 0} datastores on this backup server +

+
+ + {/* Datastore Grid */} + {!datastores || datastores.length === 0 ? ( + + ) : ( +
+ {datastores.map((datastore) => ( + onDatastoreClick?.(datastore.store)} + /> + ))} +
+ )} +
+
+ ) +} diff --git a/src/components/pbs/PbsOverview.tsx b/src/components/pbs/PbsOverview.tsx new file mode 100644 index 0000000..b3a222a --- /dev/null +++ b/src/components/pbs/PbsOverview.tsx @@ -0,0 +1,212 @@ +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { PageSkeleton } from '@/components/ui/skeleton' +import { ResourceGauge } from '@/components/dashboard/ResourceGauge' +import { AlertCircle, Cpu, Server, Clock, HardDrive, Database } from 'lucide-react' +import { + usePbsVersion, + usePbsNodeStatus, + usePbsDatastores, + queryKeys, +} from '@/hooks/usePbs' +import { formatBytes, formatUptime } from '@/lib/format' +import type { PbsDatastore } from '@/types/pbs' + +interface PbsOverviewProps { + connectionId: string +} + +function getUsageColor(percent: number): string { + if (percent >= 90) return 'bg-destructive' + if (percent >= 70) return 'bg-warning' + return 'bg-success' +} + +function DatastoreSummaryRow({ datastore }: { datastore: PbsDatastore }) { + const percent = datastore.total && datastore.total > 0 + ? ((datastore.used ?? 0) / datastore.total) * 100 + : 0 + const usageColor = getUsageColor(percent) + + return ( +
+ +
+
+ {datastore.store} + + {percent.toFixed(1)}% + +
+
+
+
+
+ {formatBytes(datastore.used ?? 0)} used + {formatBytes(datastore.total ?? 0)} total +
+
+
+ ) +} + +export function PbsOverview({ connectionId }: PbsOverviewProps) { + const queryClient = useQueryClient() + const { data: version, isLoading: versionLoading, error: versionError } = usePbsVersion(connectionId) + const { data: nodeStatus, isLoading: nodeStatusLoading, error: nodeStatusError } = usePbsNodeStatus(connectionId) + const { data: datastores, isLoading: datastoresLoading, error: datastoresError } = usePbsDatastores(connectionId) + + const handleRetry = useCallback(() => { + queryClient.refetchQueries({ queryKey: queryKeys.pbsVersion(connectionId) }) + queryClient.refetchQueries({ queryKey: queryKeys.pbsNodeStatus(connectionId) }) + queryClient.refetchQueries({ queryKey: queryKeys.pbsDatastores(connectionId) }) + }, [queryClient, connectionId]) + + if (versionLoading || nodeStatusLoading || datastoresLoading) { + return + } + + const hasError = versionError || nodeStatusError || datastoresError + if (hasError) { + return ( +
+
+
+ +
+

Unable to load server overview

+

+ {versionError?.message || nodeStatusError?.message || datastoresError?.message || 'An error occurred while loading data'} +

+

+ Check the connection to your Proxmox Backup Server and try again. +

+ +
+
+ ) + } + + const cpus = nodeStatus?.cpuinfo?.cpus ?? 1 + const usedCpu = (nodeStatus?.cpu ?? 0) * cpus + + return ( +
+
+
+

+ Overview +

+

+ Proxmox Backup Server status and resource usage +

+
+ + {/* Summary Stats Row */} +
+ + + Version +
+ +
+
+ +
+ {version?.version ?? '—'} +
+

+ release {version?.release ?? '—'} · repoid {version?.repoid ?? '—'} +

+
+
+ + + + Uptime +
+ +
+
+ +
+ {formatUptime(nodeStatus?.uptime ?? 0)} +
+

+ {nodeStatus?.currentKernel?.release ?? '—'} +

+
+
+ + + + CPU Load +
+ +
+
+ +
+ {nodeStatus?.loadavg?.[0]?.toFixed(2) ?? '—'} +
+

+ {nodeStatus?.cpuinfo?.model ?? '—'} +

+
+
+
+ + {/* Resource Gauges */} +
+ `${v.toFixed(1)} cores`} + /> + + +
+ + {/* Datastore Usage Summary */} + + + Datastores +
+ +
+
+ + {!datastores || datastores.length === 0 ? ( +

No datastores configured.

+ ) : ( + datastores.map((datastore) => ( + + )) + )} +
+
+
+
+ ) +} diff --git a/src/components/pbs/dialogs/DownloadFilesDialog.tsx b/src/components/pbs/dialogs/DownloadFilesDialog.tsx new file mode 100644 index 0000000..f9b2861 --- /dev/null +++ b/src/components/pbs/dialogs/DownloadFilesDialog.tsx @@ -0,0 +1,147 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { ScrollArea } from '@/components/ui/scroll-area' +import { EmptyState } from '@/components/ui/empty-state' +import { Skeleton } from '@/components/ui/skeleton' +import { Download, File, Lock, Loader2, AlertCircle } from 'lucide-react' +import { useConnectionStore } from '@/stores/connectionStore' +import { usePbsSnapshotFiles, usePbsDownloadFile } from '@/hooks/usePbs' +import { formatBytes } from '@/lib/format' +import type { PbsSnapshot } from '@/types/pbs' + +interface DownloadFilesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + store: string + backupId: string + backupType: PbsSnapshot['backupType'] + backupTime: number +} + +export function DownloadFilesDialog({ + open, + onOpenChange, + store, + backupId, + backupType, + backupTime, +}: DownloadFilesDialogProps) { + const activeConnectionId = useConnectionStore((s) => s.activeConnectionId) + const { + data: files, + isLoading, + error, + } = usePbsSnapshotFiles( + open ? activeConnectionId : null, + open ? store : null, + open ? backupId : null, + open ? backupType : null, + open ? backupTime : null, + ) + const downloadFile = usePbsDownloadFile() + + return ( + + + + Download Files + + Files in snapshot {backupType}/{backupId}@{backupTime} on datastore "{store}". + + + + + {isLoading ? ( +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+ ) : error ? ( +
+ + Failed to load snapshot files +
+ ) : !files || files.length === 0 ? ( + + ) : ( +
+ {files.map((file) => ( +
+
+ + {file.filename} + {file.cryptMode && file.cryptMode !== 'none' && ( + + + {file.cryptMode} + + )} +
+
+ + {formatBytes(file.size ?? 0)} + +
+ + +
+
+
+ ))} +
+ )} +
+ + + + +
+
+ ) +} diff --git a/src/components/pbs/dialogs/GcDialog.tsx b/src/components/pbs/dialogs/GcDialog.tsx new file mode 100644 index 0000000..488e1ce --- /dev/null +++ b/src/components/pbs/dialogs/GcDialog.tsx @@ -0,0 +1,47 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Loader2 } from 'lucide-react' +import { usePbsRunGc } from '@/hooks/usePbs' + +interface GcDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + store: string +} + +export function GcDialog({ open, onOpenChange, store }: GcDialogProps) { + const runGc = usePbsRunGc() + + return ( + + + + Garbage Collection + + Run garbage collection on datastore "{store}"? This removes chunks that are no + longer referenced by any backup. + + + + + + + + + ) +} diff --git a/src/components/pbs/dialogs/PruneDialog.tsx b/src/components/pbs/dialogs/PruneDialog.tsx new file mode 100644 index 0000000..fa32b06 --- /dev/null +++ b/src/components/pbs/dialogs/PruneDialog.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Loader2 } from 'lucide-react' +import { usePbsRunPrune } from '@/hooks/usePbs' + +interface PruneDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + store: string +} + +function parseOptionalInt(value: string): number | undefined { + const trimmed = value.trim() + if (!trimmed) return undefined + const n = Number(trimmed) + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined +} + +const keepFields: { key: string; label: string; placeholder: string }[] = [ + { key: 'keepLast', label: 'Keep last', placeholder: 'e.g. 7' }, + { key: 'keepDaily', label: 'Keep daily', placeholder: 'e.g. 14' }, + { key: 'keepWeekly', label: 'Keep weekly', placeholder: 'e.g. 8' }, + { key: 'keepMonthly', label: 'Keep monthly', placeholder: 'e.g. 6' }, + { key: 'keepYearly', label: 'Keep yearly', placeholder: 'e.g. 2' }, +] + +export function PruneDialog({ open, onOpenChange, store }: PruneDialogProps) { + const [keepValues, setKeepValues] = useState>({}) + const [dryRun, setDryRun] = useState(true) + const runPrune = usePbsRunPrune() + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + runPrune.mutate( + { + store, + keepLast: parseOptionalInt(keepValues.keepLast ?? ''), + keepDaily: parseOptionalInt(keepValues.keepDaily ?? ''), + keepWeekly: parseOptionalInt(keepValues.keepWeekly ?? ''), + keepMonthly: parseOptionalInt(keepValues.keepMonthly ?? ''), + keepYearly: parseOptionalInt(keepValues.keepYearly ?? ''), + dryRun, + }, + { onSuccess: () => onOpenChange(false) }, + ) + } + + return ( + + + + Prune Datastore + + Prune backup groups on datastore "{store}" according to the retention rules below. + + +
+
+ {keepFields.map((field) => ( +
+ + + setKeepValues((prev) => ({ ...prev, [field.key]: e.target.value })) + } + placeholder={field.placeholder} + /> +
+ ))} +
+ +
+ setDryRun(e.target.checked)} + className="h-4 w-4 rounded border-input accent-primary" + /> + +
+ + + + + +
+
+
+ ) +} diff --git a/src/components/pbs/dialogs/VerifyDialog.tsx b/src/components/pbs/dialogs/VerifyDialog.tsx new file mode 100644 index 0000000..d377590 --- /dev/null +++ b/src/components/pbs/dialogs/VerifyDialog.tsx @@ -0,0 +1,48 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Loader2 } from 'lucide-react' +import { usePbsRunVerify } from '@/hooks/usePbs' + +interface VerifyDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + store: string +} + +export function VerifyDialog({ open, onOpenChange, store }: VerifyDialogProps) { + const runVerify = usePbsRunVerify() + + return ( + + + + Verify Datastore + + Run verification on datastore "{store}"? Already-verified snapshots will be skipped. + + + + + + + + + ) +} diff --git a/src/hooks/usePbs.ts b/src/hooks/usePbs.ts new file mode 100644 index 0000000..01507e2 --- /dev/null +++ b/src/hooks/usePbs.ts @@ -0,0 +1,328 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import * as api from '@/lib/tauri' +import { useConnectionStore } from '@/stores/connectionStore' +import { useToast } from '@/components/ui/toast' +import type { PbsSnapshot } from '@/types/pbs' + +// Query keys +export const queryKeys = { + pbsDatastores: (id: string) => ['pbsDatastores', id], + pbsVersion: (id: string) => ['pbsVersion', id], + pbsNodeStatus: (id: string) => ['pbsNodeStatus', id], + pbsGroups: (id: string, store: string) => ['pbsGroups', id, store], + pbsSnapshots: (id: string, store: string, backupId: string, backupType: string) => [ + 'pbsSnapshots', + id, + store, + backupId, + backupType, + ], + pbsSnapshotFiles: (id: string, store: string, backupId: string, backupType: string, backupTime: number) => [ + 'pbsSnapshotFiles', + id, + store, + backupId, + backupType, + backupTime, + ], + pbsVerifyJobs: (id: string) => ['pbsVerifyJobs', id], + pbsPruneJobs: (id: string) => ['pbsPruneJobs', id], + pbsGcJobs: (id: string) => ['pbsGcJobs', id], +} + +// Queries +export const usePbsDatastores = (connectionId: string | null) => { + return useQuery({ + queryKey: queryKeys.pbsDatastores(connectionId!), + queryFn: () => api.getPbsDatastores(connectionId!), + enabled: !!connectionId, + refetchInterval: 30000, // 30 seconds + }) +} + +export const usePbsVersion = (connectionId: string | null) => { + return useQuery({ + queryKey: queryKeys.pbsVersion(connectionId!), + queryFn: () => api.getPbsVersion(connectionId!), + enabled: !!connectionId, + refetchInterval: 30000, // 30 seconds + }) +} + +export const usePbsNodeStatus = (connectionId: string | null) => { + return useQuery({ + queryKey: queryKeys.pbsNodeStatus(connectionId!), + queryFn: () => api.getPbsNodeStatus(connectionId!), + enabled: !!connectionId, + }) +} + +export const usePbsGroups = (connectionId: string | null, store: string | null) => { + return useQuery({ + queryKey: queryKeys.pbsGroups(connectionId!, store!), + queryFn: () => api.getPbsGroups(connectionId!, store!), + enabled: !!connectionId && !!store, + }) +} + +export const usePbsSnapshots = ( + connectionId: string | null, + store: string | null, + backupId: string | null, + backupType: PbsSnapshot['backupType'] | null, +) => { + return useQuery({ + queryKey: queryKeys.pbsSnapshots(connectionId!, store!, backupId!, backupType!), + queryFn: () => api.getPbsSnapshots(connectionId!, store!, backupId!, backupType!), + enabled: !!connectionId && !!store && !!backupId && !!backupType, + }) +} + +export const usePbsSnapshotFiles = ( + connectionId: string | null, + store: string | null, + backupId: string | null, + backupType: PbsSnapshot['backupType'] | null, + backupTime: number | null, +) => { + return useQuery({ + queryKey: queryKeys.pbsSnapshotFiles(connectionId!, store!, backupId!, backupType!, backupTime!), + queryFn: () => api.getPbsSnapshotFiles(connectionId!, store!, backupId!, backupType!, backupTime!), + enabled: !!connectionId && !!store && !!backupId && !!backupType && !!backupTime, + }) +} + +export const usePbsVerifyJobs = (connectionId: string | null) => { + return useQuery({ + queryKey: queryKeys.pbsVerifyJobs(connectionId!), + queryFn: () => api.getPbsVerifyJobs(connectionId!), + enabled: !!connectionId, + }) +} + +export const usePbsPruneJobs = (connectionId: string | null) => { + return useQuery({ + queryKey: queryKeys.pbsPruneJobs(connectionId!), + queryFn: () => api.getPbsPruneJobs(connectionId!), + enabled: !!connectionId, + }) +} + +export const usePbsGcJobs = (connectionId: string | null) => { + return useQuery({ + queryKey: queryKeys.pbsGcJobs(connectionId!), + queryFn: () => api.getPbsGcJobs(connectionId!), + enabled: !!connectionId, + }) +} + +// Mutations +export const usePbsDeleteSnapshot = () => { + const queryClient = useQueryClient() + const { addToast } = useToast() + + return useMutation({ + mutationFn: ({ + store, + backupId, + backupType, + backupTime, + }: { + store: string + backupId: string + backupType: PbsSnapshot['backupType'] + backupTime: number + }) => { + const connId = useConnectionStore.getState().activeConnectionId! + return api.deletePbsSnapshot(connId, store, backupId, backupType, backupTime) + }, + onSuccess: (_data, variables) => { + addToast('Snapshot deleted', 'success') + const connId = useConnectionStore.getState().activeConnectionId + if (connId) { + queryClient.invalidateQueries({ + queryKey: queryKeys.pbsSnapshots(connId, variables.store, variables.backupId, variables.backupType), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) }) + } + }, + onError: (error: Error) => { + addToast(error.message || 'Failed to delete snapshot', 'error') + }, + }) +} + +export const usePbsDeleteGroup = () => { + const queryClient = useQueryClient() + const { addToast } = useToast() + + return useMutation({ + mutationFn: ({ + store, + backupId, + backupType, + }: { + store: string + backupId: string + backupType: PbsSnapshot['backupType'] + }) => { + const connId = useConnectionStore.getState().activeConnectionId! + return api.deletePbsGroup(connId, store, backupId, backupType) + }, + onSuccess: (_data, variables) => { + addToast('Backup group deleted', 'success') + const connId = useConnectionStore.getState().activeConnectionId + if (connId) { + queryClient.invalidateQueries({ queryKey: queryKeys.pbsGroups(connId, variables.store) }) + queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) }) + } + }, + onError: (error: Error) => { + addToast(error.message || 'Failed to delete backup group', 'error') + }, + }) +} + +export const usePbsRunVerify = () => { + const queryClient = useQueryClient() + const { addToast } = useToast() + + return useMutation({ + mutationFn: ({ store }: { store: string }) => { + const connId = useConnectionStore.getState().activeConnectionId! + return api.runPbsVerify(connId, store) + }, + onSuccess: () => { + addToast('Verification started', 'success') + const connId = useConnectionStore.getState().activeConnectionId + if (connId) { + queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) }) + queryClient.invalidateQueries({ queryKey: queryKeys.pbsVerifyJobs(connId) }) + } + }, + onError: (error: Error) => { + addToast(error.message || 'Failed to start verification', 'error') + }, + }) +} + +export const usePbsRunPrune = () => { + const queryClient = useQueryClient() + const { addToast } = useToast() + + return useMutation({ + mutationFn: ({ + store, + keepLast, + keepDaily, + keepWeekly, + keepMonthly, + keepYearly, + dryRun, + }: { + store: string + keepLast?: number + keepDaily?: number + keepWeekly?: number + keepMonthly?: number + keepYearly?: number + dryRun?: boolean + }) => { + const connId = useConnectionStore.getState().activeConnectionId! + return api.runPbsPrune( + connId, + store, + keepLast, + keepDaily, + keepWeekly, + keepMonthly, + keepYearly, + dryRun ?? false, + ) + }, + onSuccess: () => { + addToast('Prune started', 'success') + const connId = useConnectionStore.getState().activeConnectionId + if (connId) { + queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) }) + queryClient.invalidateQueries({ queryKey: queryKeys.pbsPruneJobs(connId) }) + } + }, + onError: (error: Error) => { + addToast(error.message || 'Failed to start prune', 'error') + }, + }) +} + +export const usePbsRunGc = () => { + const queryClient = useQueryClient() + const { addToast } = useToast() + + return useMutation({ + mutationFn: ({ store }: { store: string }) => { + const connId = useConnectionStore.getState().activeConnectionId! + return api.runPbsGc(connId, store) + }, + onSuccess: () => { + addToast('Garbage collection started', 'success') + const connId = useConnectionStore.getState().activeConnectionId + if (connId) { + queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) }) + queryClient.invalidateQueries({ queryKey: queryKeys.pbsGcJobs(connId) }) + } + }, + onError: (error: Error) => { + addToast(error.message || 'Failed to start garbage collection', 'error') + }, + }) +} + +export const usePbsDownloadFile = () => { + const { addToast } = useToast() + + return useMutation({ + mutationFn: async ({ + store, + backupId, + backupType, + backupTime, + fileName, + decoded, + }: { + store: string + backupId: string + backupType: PbsSnapshot['backupType'] + backupTime: number + fileName: string + decoded: boolean + }): Promise => { + const connId = useConnectionStore.getState().activeConnectionId! + // In browser mock mode there is no save dialog, so default the target + // path to the file name. In Tauri mode the native save dialog picks it. + let savePath = fileName + if (api.isTauri()) { + const { save } = await import('@tauri-apps/plugin-dialog') + const chosen = await save({ defaultPath: fileName }) + if (chosen === null) return null // dialog cancelled — skip the download + savePath = chosen + } + return api.downloadPbsSnapshotFile( + connId, + store, + backupId, + backupType, + backupTime, + fileName, + decoded, + savePath, + ) + }, + onSuccess: (savePath, variables) => { + if (savePath === null) return // dialog cancelled — no toast + addToast(`Downloaded ${variables.fileName}`, 'success') + }, + onError: (error: Error) => { + addToast(error.message || 'Failed to download file', 'error') + }, + }) +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 903509f..c6f98b6 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -8,7 +8,17 @@ import type { LoadConnectionsResult, ConnectResult, ConnectionStatusInfo, + ServerType, } from '@/types/connection' +import type { + PbsDatastore, + PbsVersion, + PbsNodeStatus, + PbsBackupGroup, + PbsSnapshot, + PbsSnapshotFile, + PbsJob, +} from '@/types/pbs' import type { ProxmoxNode, ProxmoxVM, @@ -137,6 +147,7 @@ export const loginWithPassword = async ( export const loginWithToken = async ( url: string, token: string, + serverType: ServerType = 'pve', ): Promise => { if (!isTauri()) { return mockResponse({ @@ -145,7 +156,7 @@ export const loginWithToken = async ( csrfToken: '', }) } - return invokeCommand('login_with_token', { url, token }) + return invokeCommand('login_with_token', { url, token, serverType }) } export const logout = async (connectionId: string): Promise => { @@ -592,3 +603,281 @@ export const updateTrayMenu = async ( if (!isTauri()) return return invokeCommand('update_tray_menu', { connections }) } + +// Proxmox Backup Server (PBS) +export const getPbsDatastores = async (connectionId: string): Promise => { + if (!isTauri()) { + return mockResponse([ + { + store: 'backup-store', + comment: 'Main backup store', + backendType: 'filesystem', + mountStatus: 'mounted', + total: 2_000_000_000_000, + used: 800_000_000_000, + avail: 1_200_000_000_000, + }, + ]) + } + return invokeCommand('get_pbs_datastores', { connectionId }) +} + +export const getPbsVersion = async (connectionId: string): Promise => { + if (!isTauri()) { + return mockResponse({ version: '3.2.3', release: 'bookworm', repoid: 'dd6b00e2' }) + } + return invokeCommand('get_pbs_version', { connectionId }) +} + +export const getPbsNodeStatus = async (connectionId: string): Promise => { + if (!isTauri()) { + return mockResponse({ + cpu: 0.15, + loadavg: [0.42, 0.38, 0.31], + uptime: 5 * 86400, + memory: { free: 12_000_000_000, total: 32_000_000_000, used: 20_000_000_000 }, + root: { avail: 1_200_000_000_000, total: 2_000_000_000_000, used: 800_000_000_000 }, + swap: { free: 4_000_000_000, total: 4_000_000_000, used: 0 }, + cpuinfo: { cpus: 8, model: 'AMD Ryzen 7 5700G', sockets: 1 }, + currentKernel: { + machine: 'x86_64', + release: '6.8.12-4-pve', + sysname: 'Linux', + version: '#1 SMP PREEMPT_DYNAMIC', + }, + }) + } + return invokeCommand('get_pbs_node_status', { connectionId }) +} + +export const getPbsGroups = async ( + connectionId: string, + store: string, +): Promise => { + if (!isTauri()) { + return mockResponse([ + { + backupId: '100', + backupType: 'vm', + backupCount: 3, + lastBackup: 1_700_000_000, + comment: 'Web server', + }, + { + backupId: '200', + backupType: 'ct', + backupCount: 2, + lastBackup: 1_690_000_000, + comment: 'Container host', + }, + ]) + } + return invokeCommand('get_pbs_groups', { connectionId, store }) +} + +export const getPbsSnapshots = async ( + connectionId: string, + store: string, + backupId: string, + backupType: PbsSnapshot['backupType'], +): Promise => { + if (!isTauri()) { + return mockResponse([ + { + backupId, + backupType, + backupTime: 1_700_000_000, + size: 1_500_000_000, + protected: true, + comment: 'Full backup', + verification: { state: 'ok' }, + }, + { + backupId, + backupType, + backupTime: 1_700_086_400, + size: 900_000_000, + comment: 'Incremental backup', + }, + ]) + } + return invokeCommand('get_pbs_snapshots', { connectionId, store, backupId, backupType }) +} + +export const getPbsSnapshotFiles = async ( + connectionId: string, + store: string, + backupId: string, + backupType: PbsSnapshot['backupType'], + backupTime: number, +): Promise => { + if (!isTauri()) { + return mockResponse([ + { filename: 'client.conf', size: 1024 }, + { filename: 'drive-scsi0.img.fidx', size: 64 * 1024 * 1024 * 1024, cryptMode: 'none' }, + { filename: 'index.json.blob', size: 2048 }, + ]) + } + return invokeCommand('get_pbs_snapshot_files', { + connectionId, + store, + backupId, + backupType, + backupTime, + }) +} + +export const downloadPbsSnapshotFile = async ( + connectionId: string, + store: string, + backupId: string, + backupType: PbsSnapshot['backupType'], + backupTime: number, + fileName: string, + decoded: boolean, + savePath: string, +): Promise => { + if (!isTauri()) return mockResponse(savePath) + return invokeCommand('download_pbs_snapshot_file', { + connectionId, + store, + backupId, + backupType, + backupTime, + fileName, + decoded, + savePath, + }) +} + +export const deletePbsSnapshot = async ( + connectionId: string, + store: string, + backupId: string, + backupType: PbsSnapshot['backupType'], + backupTime: number, +): Promise => { + if (!isTauri()) return mockResponse(undefined) + return invokeCommand('delete_pbs_snapshot', { + connectionId, + store, + backupId, + backupType, + backupTime, + }) +} + +export const deletePbsGroup = async ( + connectionId: string, + store: string, + backupId: string, + backupType: PbsSnapshot['backupType'], +): Promise => { + if (!isTauri()) return mockResponse(undefined) + return invokeCommand('delete_pbs_group', { connectionId, store, backupId, backupType }) +} + +export const runPbsVerify = async (connectionId: string, store: string): Promise => { + if (!isTauri()) { + return mockResponse(`UPID:mock:00000000:00000000:00000000:verify:${store}::`) + } + return invokeCommand('run_pbs_verify', { connectionId, store }) +} + +export const runPbsPrune = async ( + connectionId: string, + store: string, + keepLast?: number, + keepDaily?: number, + keepWeekly?: number, + keepMonthly?: number, + keepYearly?: number, + dryRun?: boolean, +): Promise => { + if (!isTauri()) { + return mockResponse(`UPID:mock:00000000:00000000:00000000:prune:${store}::`) + } + return invokeCommand('run_pbs_prune', { + connectionId, + store, + keepLast, + keepDaily, + keepWeekly, + keepMonthly, + keepYearly, + dryRun, + }) +} + +export const runPbsGc = async (connectionId: string, store: string): Promise => { + if (!isTauri()) { + return mockResponse(`UPID:mock:00000000:00000000:00000000:gc:${store}::`) + } + return invokeCommand('run_pbs_gc', { connectionId, store }) +} + +export const getPbsVerifyJobs = async ( + connectionId: string, + store?: string, +): Promise => { + if (!isTauri()) { + return mockResponse([ + { + id: 'verify-1', + store: 'backup-store', + schedule: 'sun 01:00', + comment: 'Weekly verification', + lastRunState: 'OK', + lastRunEndtime: 1_700_000_000, + nextRun: 1_700_600_000, + maxDepth: 5, + }, + ]) + } + return invokeCommand('get_pbs_verify_jobs', { connectionId, store }) +} + +export const getPbsPruneJobs = async ( + connectionId: string, + store?: string, +): Promise => { + if (!isTauri()) { + return mockResponse([ + { + id: 'prune-1', + store: 'backup-store', + schedule: 'sat 02:00', + comment: 'Weekly pruning', + lastRunState: 'OK', + lastRunEndtime: 1_690_000_000, + nextRun: 1_700_000_000, + keepLast: 7, + keepDaily: 14, + keepWeekly: 8, + keepMonthly: 6, + keepYearly: 2, + }, + ]) + } + return invokeCommand('get_pbs_prune_jobs', { connectionId, store }) +} + +export const getPbsGcJobs = async ( + connectionId: string, + store?: string, +): Promise => { + if (!isTauri()) { + return mockResponse([ + { + id: 'gc-1', + store: 'backup-store', + schedule: 'mon 03:00', + comment: 'Weekly garbage collection', + lastRunState: 'OK', + lastRunEndtime: 1_690_000_000, + nextRun: 1_700_000_000, + }, + ]) + } + return invokeCommand('get_pbs_gc_jobs', { connectionId, store }) +} diff --git a/src/types/connection.ts b/src/types/connection.ts index ffc6b51..db3323d 100644 --- a/src/types/connection.ts +++ b/src/types/connection.ts @@ -2,6 +2,10 @@ export type AuthMode = 'password' | 'token' +/** The kind of server a connection targets. Absent on persisted old + * connections, where it is treated as 'pve'. */ +export type ServerType = 'pve' | 'pbs' + export interface ConnectionConfig { id: string name: string @@ -16,6 +20,8 @@ export interface ConnectionConfig { isCluster: boolean authMode: AuthMode username?: string + /** The kind of server this connection targets ('pve' when absent). */ + serverType?: ServerType /** Nodes discovered in the cluster this connection is anchored on. */ nodes?: DiscoveredNode[] /** The endpoint currently serving this connection (after failover). */ diff --git a/src/types/pbs.ts b/src/types/pbs.ts new file mode 100644 index 0000000..f1b9c1a --- /dev/null +++ b/src/types/pbs.ts @@ -0,0 +1,97 @@ +// Proxmox Backup Server (PBS) API types +// +// PBS exposes kebab-case JSON fields; the Rust backend maps them to camelCase +// structs (serde `rename_all = "camelCase"`), so these types mirror the +// backend's shapes. All fields are optional unless marked required. + +export interface PbsDatastore { + store: string // required + comment?: string + backendType?: string // 'filesystem' | 's3' + mountStatus?: string // 'mounted' | 'notmounted' | 'nonremovable' + maintenance?: string + total?: number + used?: number + avail?: number + error?: string + estimatedFullDate?: number + history?: number[] + gcStatus?: { + diskBytes?: number + diskChunks?: number + indexDataBytes?: number + indexFileCount?: number + pendingBytes?: number + pendingChunks?: number + removedBad?: number + removedBytes?: number + removedChunks?: number + stillBad?: number + cacheHits?: number + cacheMisses?: number + upid?: string + } +} + +export interface PbsVersion { + version: string + release: string + repoid: string +} + +export interface PbsNodeStatus { + cpu?: number + loadavg?: number[] + uptime?: number + memory?: { free?: number; total?: number; used?: number } + root?: { avail?: number; total?: number; used?: number } + swap?: { free?: number; total?: number; used?: number } + cpuinfo?: { cpus?: number; model?: string; sockets?: number } + currentKernel?: { machine?: string; release?: string; sysname?: string; version?: string } +} + +export interface PbsBackupGroup { + backupId: string // required, e.g. "100" + backupType: 'vm' | 'ct' | 'host' + backupCount?: number + lastBackup?: number // unix epoch + comment?: string + files?: string[] +} + +export interface PbsSnapshot { + backupId: string // required + backupType: 'vm' | 'ct' | 'host' + backupTime: number // required, unix epoch + size?: number + protected?: boolean + comment?: string + files?: string[] + fingerprint?: string + owner?: string + verification?: { state?: 'ok' | 'failed'; upid?: string } +} + +export interface PbsSnapshotFile { + filename: string + size?: number + cryptMode?: string +} + +export interface PbsJob { + id: string // required + store?: string + schedule?: string + comment?: string + disable?: boolean + lastRunState?: string + lastRunEndtime?: number + nextRun?: number + keepLast?: number + keepDaily?: number + keepWeekly?: number + keepMonthly?: number + keepYearly?: number + ignoreVerified?: boolean + maxDepth?: number +}