+
+ PVEAPIToken
+
+
+ PVEAuthCookie + CSRFPreventionToken
+
+
+ Headers injected by the backend
+
+
+
+
+
+
+
failover model
+
+ Requests ride an ordered, deduplicated endpoint list: primary,
+ fallbacks, then discovered cluster nodes. A connect, timeout, DNS, or
+ transport failure retries on the next endpoint.
+
+ Requests ride an ordered endpoint list: primary, fallbacks, then
+ discovered cluster nodes. When one dies, the next takes over, and the
+ status flips to failover so you always know which node answered.
+
+
+
+
+
+ request → endpoints[0] → retry on failure
+
+
+
+
+ 192.168.1.10:8006
+
+
+ Primary
+
+
+
+
+ 192.168.1.11:8006
+
+
+ Fallback
+
+
+
+
+ pve3.discovery
+
+
+ Cluster
+
+
+
+
+
+ status: failover
+
+
+
+
+
+
+
+
+
+
+
+
+ 02
+
+ Credentials never touch disk
+
+
+
+ API tokens live in the OS keyring. Self-signed certificates are pinned by
+ SHA-256 fingerprint on first connect and verified on every connect after.
+
+ Tasks and events stream over WebSocket, with polling as the fallback.
+ Cmd/Ctrl+K opens the command palette. The tray menu keeps one-click
+ actions one click away.
+
+
+
+
+
+ Cmd/Ctrl + K
+
+
Command palette
+
+
+ WebSocket
+
+ Live task & event stream
+
+
+
+ Tray menu
+
+ Show / hide · connections · quit
+
+
+
+ 10s
+
+ Connection status polling
+
+
+
+
+
diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro
new file mode 100644
index 0000000..0151623
--- /dev/null
+++ b/site/src/components/Footer.astro
@@ -0,0 +1,78 @@
+---
+const year = new Date().getFullYear()
+---
+
+
+
+
diff --git a/site/src/components/Hero.astro b/site/src/components/Hero.astro
new file mode 100644
index 0000000..9da8407
--- /dev/null
+++ b/site/src/components/Hero.astro
@@ -0,0 +1,77 @@
+---
+import AppWindowMockup from './AppWindowMockup.astro'
+---
+
+
+
+
+
+ OPS CONSOLE
+
+
+ One console for your Proxmox cluster.
+
+
+ Manage Proxmox VE nodes, VMs, storage, and backups from a desktop app that keeps working when a node goes down.
+
+
+
+
+
+
+
diff --git a/site/src/components/Nav.astro b/site/src/components/Nav.astro
new file mode 100644
index 0000000..d72d5eb
--- /dev/null
+++ b/site/src/components/Nav.astro
@@ -0,0 +1,52 @@
+
+
+
+
+
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
new file mode 100644
index 0000000..366537b
--- /dev/null
+++ b/site/src/layouts/Base.astro
@@ -0,0 +1,21 @@
+---
+import '@fontsource-variable/geist'
+import '@fontsource-variable/jetbrains-mono'
+import '../styles/global.css'
+
+interface Props { title?: string; description?: string }
+const { title = 'Clustri', description = 'A desktop client for managing Proxmox VE servers and clusters.' } = Astro.props
+---
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
new file mode 100644
index 0000000..b9fa056
--- /dev/null
+++ b/site/src/pages/index.astro
@@ -0,0 +1,23 @@
+---
+import Base from '../layouts/Base.astro'
+import Nav from '../components/Nav.astro'
+import Hero from '../components/Hero.astro'
+import FeatureBlocks from '../components/FeatureBlocks.astro'
+import ArchitectureStrip from '../components/ArchitectureStrip.astro'
+import DownloadCta from '../components/DownloadCta.astro'
+import Footer from '../components/Footer.astro'
+---
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/src/styles/global.css b/site/src/styles/global.css
new file mode 100644
index 0000000..49183bb
--- /dev/null
+++ b/site/src/styles/global.css
@@ -0,0 +1,147 @@
+@import 'tailwindcss';
+
+@theme {
+ /* ------------------------------------------------------------------
+ * Typography — offline-safe variable fonts (bundled via fontsource)
+ * ------------------------------------------------------------------ */
+ --font-sans:
+ 'Geist Variable', 'Geist', ui-sans-serif, system-ui, -apple-system,
+ 'Segoe UI', sans-serif;
+ --font-mono:
+ 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono',
+ Menlo, monospace;
+
+ /* ------------------------------------------------------------------
+ * Radius scale — tighter on inner elements, softer on containers
+ * ------------------------------------------------------------------ */
+ --radius: 0.625rem;
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.625rem;
+ --radius-xl: 0.75rem;
+
+ /* ------------------------------------------------------------------
+ * Dark theme — blue-black ink surfaces, cool neutrals, muted orange.
+ * The marketing site is dark-only: the app's `.dark` palette is the
+ * single theme (no light/dark toggle by design).
+ * ------------------------------------------------------------------ */
+ --color-background: oklch(0.16 0.011 250);
+ --color-foreground: oklch(0.965 0.004 250);
+ --color-card: oklch(0.19 0.012 250);
+ --color-card-foreground: oklch(0.965 0.004 250);
+ --color-popover: oklch(0.215 0.013 250);
+ --color-popover-foreground: oklch(0.965 0.004 250);
+ --color-primary: oklch(0.72 0.15 60);
+ --color-primary-foreground: oklch(0.18 0.02 50);
+ --color-secondary: oklch(0.235 0.011 250);
+ --color-secondary-foreground: oklch(0.94 0.004 250);
+ --color-muted: oklch(0.235 0.011 250);
+ --color-muted-foreground: oklch(0.655 0.012 250);
+ --color-accent: oklch(0.275 0.012 250);
+ --color-accent-foreground: oklch(0.965 0.004 250);
+ --color-destructive: oklch(0.645 0.18 25);
+ --color-destructive-foreground: oklch(0.97 0.015 25);
+ --color-border: oklch(0.255 0.012 250);
+ --color-input: oklch(0.3 0.013 250);
+ --color-ring: oklch(0.72 0.15 60);
+
+ /* Semantic status colors — data semantics, not UI accents */
+ --color-success: oklch(0.7 0.14 155);
+ --color-warning: oklch(0.76 0.13 80);
+ --color-info: oklch(0.68 0.11 245);
+
+ /* Tinted card shadow — carries the cool hue of the dark surface */
+ --shadow-card:
+ 0 1px 2px 0 oklch(0.06 0.01 250 / 0.4), 0 2px 10px -2px oklch(0.06 0.01 250 / 0.35);
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+
+ html {
+ color-scheme: dark;
+ }
+
+ html {
+ scroll-behavior: smooth;
+ }
+
+ body {
+ @apply bg-background font-sans text-foreground antialiased;
+ }
+
+ /* Keep native form controls and webview widgets on theme */
+ ::selection {
+ @apply bg-primary/20 text-foreground;
+ }
+
+ /* Monospace and code contexts always use tabular numerals */
+ code,
+ kbd,
+ samp,
+ pre {
+ font-variant-numeric: tabular-nums;
+ }
+
+ /* Consistent focus ring everywhere, including custom elements */
+ :focus-visible {
+ @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
+ }
+
+ /* Native scrollbars tuned to the surface language */
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-border) transparent;
+ }
+
+ ::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background-color: var(--color-border);
+ border-radius: 999px;
+ border: 3px solid transparent;
+ background-clip: padding-box;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: var(--color-muted-foreground);
+ background-clip: padding-box;
+ }
+
+ /* Very subtle fixed grain overlay to break digital flatness */
+ body::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ z-index: 60;
+ pointer-events: none;
+ opacity: 0.04;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
+ background-repeat: repeat;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+}
diff --git a/site/tsconfig.json b/site/tsconfig.json
new file mode 100644
index 0000000..bcbf8b5
--- /dev/null
+++ b/site/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "astro/tsconfigs/strict"
+}
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index c2c4178..8cbc05f 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -429,7 +429,6 @@ dependencies = [
"sha2",
"tauri",
"tauri-build",
- "tauri-plugin-shell",
"tempfile",
"thiserror 2.0.19",
"tokio",
@@ -884,15 +883,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
-[[package]]
-name = "encoding_rs"
-version = "0.8.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
-dependencies = [
- "cfg-if",
-]
-
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1818,25 +1808,6 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
-[[package]]
-name = "is-docker"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
-dependencies = [
- "once_cell",
-]
-
-[[package]]
-name = "is-wsl"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
-dependencies = [
- "is-docker",
- "once_cell",
-]
-
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2422,33 +2393,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
-[[package]]
-name = "open"
-version = "5.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
-dependencies = [
- "dunce",
- "is-wsl",
- "libc",
-]
-
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
-[[package]]
-name = "os_pipe"
-version = "1.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
-dependencies = [
- "libc",
- "windows-sys 0.61.2",
-]
-
[[package]]
name = "pango"
version = "0.18.3"
@@ -3469,44 +3419,12 @@ dependencies = [
"digest",
]
-[[package]]
-name = "shared_child"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
-dependencies = [
- "libc",
- "sigchld",
- "windows-sys 0.60.2",
-]
-
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
-[[package]]
-name = "sigchld"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
-dependencies = [
- "libc",
- "os_pipe",
- "signal-hook",
-]
-
-[[package]]
-name = "signal-hook"
-version = "0.3.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
-dependencies = [
- "libc",
- "signal-hook-registry",
-]
-
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -3918,43 +3836,6 @@ 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-shell"
-version = "2.3.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b"
-dependencies = [
- "encoding_rs",
- "log",
- "open",
- "os_pipe",
- "regex",
- "schemars 0.8.22",
- "serde",
- "serde_json",
- "shared_child",
- "tauri",
- "tauri-plugin",
- "thiserror 2.0.19",
- "tokio",
-]
-
[[package]]
name = "tauri-runtime"
version = "2.11.3"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index b5deb38..0e332d9 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -10,7 +10,6 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
-tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 7449cdb..65b1c54 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -14,8 +14,6 @@
"core:window:allow-hide",
"core:window:allow-close",
"core:window:allow-set-focus",
- "core:window:allow-is-visible",
- "shell:allow-execute",
- "shell:default"
+ "core:window:allow-is-visible"
]
}
diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json
index f43b852..0eebfc4 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},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}}
\ 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}}
\ No newline at end of file
diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json
index a131f2f..8351bae 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","shell:allow-execute","shell:default"]}}
\ 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"]}}
\ 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 d1e5361..3286645 100644
--- a/src-tauri/gen/schemas/desktop-schema.json
+++ b/src-tauri/gen/schemas/desktop-schema.json
@@ -134,216 +134,6 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
- {
- "if": {
- "properties": {
- "identifier": {
- "anyOf": [
- {
- "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
- "type": "string",
- "const": "shell:default",
- "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
- },
- {
- "description": "Enables the execute command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-execute",
- "markdownDescription": "Enables the execute command without any pre-configured scope."
- },
- {
- "description": "Enables the kill command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-kill",
- "markdownDescription": "Enables the kill command without any pre-configured scope."
- },
- {
- "description": "Enables the open command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-open",
- "markdownDescription": "Enables the open command without any pre-configured scope."
- },
- {
- "description": "Enables the spawn command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-spawn",
- "markdownDescription": "Enables the spawn command without any pre-configured scope."
- },
- {
- "description": "Enables the stdin_write command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-stdin-write",
- "markdownDescription": "Enables the stdin_write command without any pre-configured scope."
- },
- {
- "description": "Denies the execute command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-execute",
- "markdownDescription": "Denies the execute command without any pre-configured scope."
- },
- {
- "description": "Denies the kill command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-kill",
- "markdownDescription": "Denies the kill command without any pre-configured scope."
- },
- {
- "description": "Denies the open command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-open",
- "markdownDescription": "Denies the open command without any pre-configured scope."
- },
- {
- "description": "Denies the spawn command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-spawn",
- "markdownDescription": "Denies the spawn command without any pre-configured scope."
- },
- {
- "description": "Denies the stdin_write command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-stdin-write",
- "markdownDescription": "Denies the stdin_write command without any pre-configured scope."
- }
- ]
- }
- }
- },
- "then": {
- "properties": {
- "allow": {
- "items": {
- "title": "ShellScopeEntry",
- "description": "Shell scope entry.",
- "anyOf": [
- {
- "type": "object",
- "required": [
- "cmd",
- "name"
- ],
- "properties": {
- "args": {
- "description": "The allowed arguments for the command execution.",
- "allOf": [
- {
- "$ref": "#/definitions/ShellScopeEntryAllowedArgs"
- }
- ]
- },
- "cmd": {
- "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
- "type": "string"
- },
- "name": {
- "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
- "type": "string"
- }
- },
- "additionalProperties": false
- },
- {
- "type": "object",
- "required": [
- "name",
- "sidecar"
- ],
- "properties": {
- "args": {
- "description": "The allowed arguments for the command execution.",
- "allOf": [
- {
- "$ref": "#/definitions/ShellScopeEntryAllowedArgs"
- }
- ]
- },
- "name": {
- "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
- "type": "string"
- },
- "sidecar": {
- "description": "If this command is a sidecar command.",
- "type": "boolean"
- }
- },
- "additionalProperties": false
- }
- ]
- }
- },
- "deny": {
- "items": {
- "title": "ShellScopeEntry",
- "description": "Shell scope entry.",
- "anyOf": [
- {
- "type": "object",
- "required": [
- "cmd",
- "name"
- ],
- "properties": {
- "args": {
- "description": "The allowed arguments for the command execution.",
- "allOf": [
- {
- "$ref": "#/definitions/ShellScopeEntryAllowedArgs"
- }
- ]
- },
- "cmd": {
- "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
- "type": "string"
- },
- "name": {
- "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
- "type": "string"
- }
- },
- "additionalProperties": false
- },
- {
- "type": "object",
- "required": [
- "name",
- "sidecar"
- ],
- "properties": {
- "args": {
- "description": "The allowed arguments for the command execution.",
- "allOf": [
- {
- "$ref": "#/definitions/ShellScopeEntryAllowedArgs"
- }
- ]
- },
- "name": {
- "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
- "type": "string"
- },
- "sidecar": {
- "description": "If this command is a sidecar command.",
- "type": "boolean"
- }
- },
- "additionalProperties": false
- }
- ]
- }
- }
- }
- },
- "properties": {
- "identifier": {
- "description": "Identifier of the permission or permission set.",
- "allOf": [
- {
- "$ref": "#/definitions/Identifier"
- }
- ]
- }
- }
- },
{
"properties": {
"identifier": {
@@ -2401,72 +2191,6 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
- },
- {
- "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
- "type": "string",
- "const": "shell:default",
- "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
- },
- {
- "description": "Enables the execute command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-execute",
- "markdownDescription": "Enables the execute command without any pre-configured scope."
- },
- {
- "description": "Enables the kill command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-kill",
- "markdownDescription": "Enables the kill command without any pre-configured scope."
- },
- {
- "description": "Enables the open command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-open",
- "markdownDescription": "Enables the open command without any pre-configured scope."
- },
- {
- "description": "Enables the spawn command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-spawn",
- "markdownDescription": "Enables the spawn command without any pre-configured scope."
- },
- {
- "description": "Enables the stdin_write command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-stdin-write",
- "markdownDescription": "Enables the stdin_write command without any pre-configured scope."
- },
- {
- "description": "Denies the execute command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-execute",
- "markdownDescription": "Denies the execute command without any pre-configured scope."
- },
- {
- "description": "Denies the kill command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-kill",
- "markdownDescription": "Denies the kill command without any pre-configured scope."
- },
- {
- "description": "Denies the open command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-open",
- "markdownDescription": "Denies the open command without any pre-configured scope."
- },
- {
- "description": "Denies the spawn command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-spawn",
- "markdownDescription": "Denies the spawn command without any pre-configured scope."
- },
- {
- "description": "Denies the stdin_write command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-stdin-write",
- "markdownDescription": "Denies the stdin_write command without any pre-configured scope."
}
]
},
@@ -2563,50 +2287,6 @@
]
}
]
- },
- "ShellScopeEntryAllowedArg": {
- "description": "A command argument allowed to be executed by the webview API.",
- "anyOf": [
- {
- "description": "A non-configurable argument that is passed to the command in the order it was specified.",
- "type": "string"
- },
- {
- "description": "A variable that is set while calling the command from the webview API.",
- "type": "object",
- "required": [
- "validator"
- ],
- "properties": {
- "raw": {
- "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
- "default": false,
- "type": "boolean"
- },
- "validator": {
- "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ",
- "type": "string"
- }
- },
- "additionalProperties": false
- }
- ]
- },
- "ShellScopeEntryAllowedArgs": {
- "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
- "anyOf": [
- {
- "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.",
- "type": "boolean"
- },
- {
- "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.",
- "type": "array",
- "items": {
- "$ref": "#/definitions/ShellScopeEntryAllowedArg"
- }
- }
- ]
}
}
}
\ No newline at end of file
diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json
index d1e5361..3286645 100644
--- a/src-tauri/gen/schemas/linux-schema.json
+++ b/src-tauri/gen/schemas/linux-schema.json
@@ -134,216 +134,6 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
- {
- "if": {
- "properties": {
- "identifier": {
- "anyOf": [
- {
- "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
- "type": "string",
- "const": "shell:default",
- "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
- },
- {
- "description": "Enables the execute command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-execute",
- "markdownDescription": "Enables the execute command without any pre-configured scope."
- },
- {
- "description": "Enables the kill command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-kill",
- "markdownDescription": "Enables the kill command without any pre-configured scope."
- },
- {
- "description": "Enables the open command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-open",
- "markdownDescription": "Enables the open command without any pre-configured scope."
- },
- {
- "description": "Enables the spawn command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-spawn",
- "markdownDescription": "Enables the spawn command without any pre-configured scope."
- },
- {
- "description": "Enables the stdin_write command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-stdin-write",
- "markdownDescription": "Enables the stdin_write command without any pre-configured scope."
- },
- {
- "description": "Denies the execute command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-execute",
- "markdownDescription": "Denies the execute command without any pre-configured scope."
- },
- {
- "description": "Denies the kill command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-kill",
- "markdownDescription": "Denies the kill command without any pre-configured scope."
- },
- {
- "description": "Denies the open command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-open",
- "markdownDescription": "Denies the open command without any pre-configured scope."
- },
- {
- "description": "Denies the spawn command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-spawn",
- "markdownDescription": "Denies the spawn command without any pre-configured scope."
- },
- {
- "description": "Denies the stdin_write command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-stdin-write",
- "markdownDescription": "Denies the stdin_write command without any pre-configured scope."
- }
- ]
- }
- }
- },
- "then": {
- "properties": {
- "allow": {
- "items": {
- "title": "ShellScopeEntry",
- "description": "Shell scope entry.",
- "anyOf": [
- {
- "type": "object",
- "required": [
- "cmd",
- "name"
- ],
- "properties": {
- "args": {
- "description": "The allowed arguments for the command execution.",
- "allOf": [
- {
- "$ref": "#/definitions/ShellScopeEntryAllowedArgs"
- }
- ]
- },
- "cmd": {
- "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
- "type": "string"
- },
- "name": {
- "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
- "type": "string"
- }
- },
- "additionalProperties": false
- },
- {
- "type": "object",
- "required": [
- "name",
- "sidecar"
- ],
- "properties": {
- "args": {
- "description": "The allowed arguments for the command execution.",
- "allOf": [
- {
- "$ref": "#/definitions/ShellScopeEntryAllowedArgs"
- }
- ]
- },
- "name": {
- "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
- "type": "string"
- },
- "sidecar": {
- "description": "If this command is a sidecar command.",
- "type": "boolean"
- }
- },
- "additionalProperties": false
- }
- ]
- }
- },
- "deny": {
- "items": {
- "title": "ShellScopeEntry",
- "description": "Shell scope entry.",
- "anyOf": [
- {
- "type": "object",
- "required": [
- "cmd",
- "name"
- ],
- "properties": {
- "args": {
- "description": "The allowed arguments for the command execution.",
- "allOf": [
- {
- "$ref": "#/definitions/ShellScopeEntryAllowedArgs"
- }
- ]
- },
- "cmd": {
- "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
- "type": "string"
- },
- "name": {
- "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
- "type": "string"
- }
- },
- "additionalProperties": false
- },
- {
- "type": "object",
- "required": [
- "name",
- "sidecar"
- ],
- "properties": {
- "args": {
- "description": "The allowed arguments for the command execution.",
- "allOf": [
- {
- "$ref": "#/definitions/ShellScopeEntryAllowedArgs"
- }
- ]
- },
- "name": {
- "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
- "type": "string"
- },
- "sidecar": {
- "description": "If this command is a sidecar command.",
- "type": "boolean"
- }
- },
- "additionalProperties": false
- }
- ]
- }
- }
- }
- },
- "properties": {
- "identifier": {
- "description": "Identifier of the permission or permission set.",
- "allOf": [
- {
- "$ref": "#/definitions/Identifier"
- }
- ]
- }
- }
- },
{
"properties": {
"identifier": {
@@ -2401,72 +2191,6 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
- },
- {
- "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
- "type": "string",
- "const": "shell:default",
- "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
- },
- {
- "description": "Enables the execute command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-execute",
- "markdownDescription": "Enables the execute command without any pre-configured scope."
- },
- {
- "description": "Enables the kill command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-kill",
- "markdownDescription": "Enables the kill command without any pre-configured scope."
- },
- {
- "description": "Enables the open command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-open",
- "markdownDescription": "Enables the open command without any pre-configured scope."
- },
- {
- "description": "Enables the spawn command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-spawn",
- "markdownDescription": "Enables the spawn command without any pre-configured scope."
- },
- {
- "description": "Enables the stdin_write command without any pre-configured scope.",
- "type": "string",
- "const": "shell:allow-stdin-write",
- "markdownDescription": "Enables the stdin_write command without any pre-configured scope."
- },
- {
- "description": "Denies the execute command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-execute",
- "markdownDescription": "Denies the execute command without any pre-configured scope."
- },
- {
- "description": "Denies the kill command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-kill",
- "markdownDescription": "Denies the kill command without any pre-configured scope."
- },
- {
- "description": "Denies the open command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-open",
- "markdownDescription": "Denies the open command without any pre-configured scope."
- },
- {
- "description": "Denies the spawn command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-spawn",
- "markdownDescription": "Denies the spawn command without any pre-configured scope."
- },
- {
- "description": "Denies the stdin_write command without any pre-configured scope.",
- "type": "string",
- "const": "shell:deny-stdin-write",
- "markdownDescription": "Denies the stdin_write command without any pre-configured scope."
}
]
},
@@ -2563,50 +2287,6 @@
]
}
]
- },
- "ShellScopeEntryAllowedArg": {
- "description": "A command argument allowed to be executed by the webview API.",
- "anyOf": [
- {
- "description": "A non-configurable argument that is passed to the command in the order it was specified.",
- "type": "string"
- },
- {
- "description": "A variable that is set while calling the command from the webview API.",
- "type": "object",
- "required": [
- "validator"
- ],
- "properties": {
- "raw": {
- "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
- "default": false,
- "type": "boolean"
- },
- "validator": {
- "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ",
- "type": "string"
- }
- },
- "additionalProperties": false
- }
- ]
- },
- "ShellScopeEntryAllowedArgs": {
- "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
- "anyOf": [
- {
- "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.",
- "type": "boolean"
- },
- {
- "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.",
- "type": "array",
- "items": {
- "$ref": "#/definitions/ShellScopeEntryAllowedArg"
- }
- }
- ]
}
}
}
\ No newline at end of file
diff --git a/src-tauri/src/connection.rs b/src-tauri/src/connection.rs
index 3f3cc4e..baf2ee6 100644
--- a/src-tauri/src/connection.rs
+++ b/src-tauri/src/connection.rs
@@ -2,7 +2,7 @@ use crate::error::Error;
use crate::proxmox::{
AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterNode, ClusterStatus,
CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot,
- Storage, StorageContent, StorageDetail, Task, VM,
+ Storage, StorageContent, StorageDetail, Task, UpdateVMConfig, VM,
};
use crate::{
CertificateInfo, ConnectResult, ConnectionConfig, ConnectionStatusInfo, DiscoveredNode,
@@ -11,9 +11,10 @@ use crate::{
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use reqwest::{Client, Method};
use serde::{Deserialize, Serialize};
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Mutex;
+use std::time::Duration;
use url::Url;
/// Characters left unencoded when percent-encoding a volume id into a URL
@@ -27,6 +28,29 @@ const VOLID_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'_')
.remove(b'~');
+/// Characters left unencoded when percent-encoding a single URL path segment
+/// (node names, snapshot names). Same set as [`VOLID_ENCODE_SET`] but without
+/// the `:` that separates a storage prefix from a volid.
+const SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
+ .remove(b'-')
+ .remove(b'.')
+ .remove(b'_')
+ .remove(b'~');
+
+/// Builds the HTTP client used for every connection. TLS verification is
+/// intentionally off at the transport layer (self-signed Proxmox servers are
+/// the norm); trust is enforced by the application-level TOFU pinning in
+/// `tls.rs`. Connect and total timeouts keep a dead server from hanging the
+/// UI forever.
+fn build_client() -> crate::Result {
+ Client::builder()
+ .danger_accept_invalid_certs(true)
+ .connect_timeout(Duration::from_secs(10))
+ .timeout(Duration::from_secs(30))
+ .build()
+ .map_err(Error::HttpError)
+}
+
/// The result of loading persisted connections from disk.
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -99,7 +123,12 @@ where
pub fn derive_node_url(primary_url: &str, node_ip: Option<&str>, node_name: &str) -> String {
let host = node_ip.filter(|ip| !ip.is_empty()).unwrap_or(node_name);
match Url::parse(primary_url) {
- Ok(url) => format!("{}://{}:{}", url.scheme(), host, url.port().unwrap_or(8006)),
+ Ok(url) => {
+ // pveproxy listens on 8006 for both http and https, but a scheme
+ // default applies when no explicit port is given (`http` → 80).
+ let default_port = if url.scheme() == "http" { 80 } else { 8006 };
+ format!("{}://{}:{}", url.scheme(), host, url.port().unwrap_or(default_port))
+ }
Err(_) => format!("https://{}:8006", host),
}
}
@@ -182,19 +211,62 @@ pub async fn api_request(
});
if !status.is_success() {
- let message = body["errors"]
- .as_str()
- .or_else(|| body["message"].as_str())
- .or_else(|| body["data"].as_str())
- .or_else(|| body.as_str())
- .map(str::to_string)
- .unwrap_or_else(|| format!("Proxmox API error (HTTP {})", status.as_u16()));
+ let message = error_message_from_body(&body, status.as_u16());
return Err(Error::ApiError(message));
}
Ok(body.get("data").cloned().unwrap_or(body))
}
+/// Builds the error message surfaced for a non-success API response.
+///
+/// The `errors` field is preferred over the generic `message` (Proxmox
+/// parameter-verification failures send a body like
+/// `{"errors":{"limit":"property is not defined in schema"},
+/// "message":"Parameter verification failed."}`, and the specific `errors`
+/// entry is what tells the user what went wrong). The precedence is:
+/// 1. `errors` as a string
+/// 2. `errors` as an object — the first `key: value` pair, formatted as
+/// `key: value` (string values verbatim, other values as JSON)
+/// 3. `message` as a string
+/// 4. `data` as a string
+/// 5. the raw body when it is itself a string
+/// 6. a generic `Proxmox API error (HTTP {status})` fallback
+fn error_message_from_body(body: &serde_json::Value, status: u16) -> String {
+ if let Some(errors) = body.get("errors") {
+ if let Some(text) = errors.as_str() {
+ if !text.is_empty() {
+ return text.to_string();
+ }
+ }
+ if let Some(obj) = errors.as_object() {
+ if let Some((key, value)) = obj.iter().next() {
+ let value_text = value
+ .as_str()
+ .map(str::to_string)
+ .unwrap_or_else(|| value.to_string());
+ return format!("{}: {}", key, value_text);
+ }
+ }
+ }
+ if let Some(message) = body.get("message").and_then(|m| m.as_str()) {
+ if !message.is_empty() {
+ return message.to_string();
+ }
+ }
+ if let Some(data) = body.get("data").and_then(|d| d.as_str()) {
+ if !data.is_empty() {
+ return data.to_string();
+ }
+ }
+ if let Some(text) = body.as_str() {
+ if !text.is_empty() {
+ return text.to_string();
+ }
+ }
+ format!("Proxmox API error (HTTP {})", status)
+}
+
struct Connection {
config: ConnectionConfig,
client: Client,
@@ -393,13 +465,9 @@ impl ConnectionManager {
/// Builds the HTTP client and session state for a connection.
fn build_connection(config: ConnectionConfig) -> crate::Result {
- let client = Client::builder()
- .danger_accept_invalid_certs(true)
- .build()
- .map_err(Error::HttpError)?;
Ok(Connection {
config,
- client,
+ client: build_client()?,
ticket: Mutex::new(None),
csrf_token: Mutex::new(None),
current_endpoint_index: Mutex::new(0),
@@ -906,10 +974,7 @@ impl ConnectionManager {
return Err(Error::InvalidUrl("URL cannot be empty".to_string()));
}
- let client = Client::builder()
- .danger_accept_invalid_certs(true)
- .build()
- .map_err(|e| Error::HttpError(e))?;
+ let client = build_client()?;
let login_url = format!("{}/access/ticket", url);
@@ -999,10 +1064,7 @@ impl ConnectionManager {
));
}
- let client = Client::builder()
- .danger_accept_invalid_certs(true)
- .build()
- .map_err(|e| Error::HttpError(e))?;
+ let client = build_client()?;
// Validate the token by making an authenticated request
let test_url = format!("{}/cluster/status", url);
@@ -1117,6 +1179,22 @@ impl ConnectionManager {
Ok(())
}
+ /// Injects a password-mode session (ticket + CSRF token) into an existing
+ /// connection's in-memory state without touching the OS keyring. Used by
+ /// integration tests that authenticate against a live server where no
+ /// keyring secret-service is available; any later login or ticket refresh
+ /// overwrites the values.
+ pub async fn set_session_ticket(
+ &self,
+ connection_id: &str,
+ ticket: &str,
+ csrf_token: &str,
+ ) -> crate::Result<()> {
+ let conn = self.connection(connection_id)?;
+ conn.set_session(ticket.to_string(), csrf_token.to_string());
+ Ok(())
+ }
+
pub async fn refresh_ticket(
&self,
connection_id: &str,
@@ -1274,19 +1352,52 @@ impl ConnectionManager {
let data = conn
.request(Method::GET, "/cluster/resources", &query, None)
.await?;
- parse_api("/cluster/resources?type=storage", data)
+ // `/cluster/resources?type=storage` entries carry usage as `disk` /
+ // `maxdisk` and an `available`/`unavailable` status string — not the
+ // `used`/`total`/`avail`/`enabled`/`active` fields the struct models.
+ // Each entry is mapped manually so the overview shows real numbers.
+ let entries: Vec =
+ parse_api("/cluster/resources?type=storage", data)?;
+ let mut storages = Vec::with_capacity(entries.len());
+ for entry in entries {
+ let disk = entry["disk"].as_u64().unwrap_or(0);
+ let maxdisk = entry["maxdisk"].as_u64().unwrap_or(0);
+ let status = entry["status"].as_str().unwrap_or("");
+ let available = status == "available";
+ storages.push(Storage {
+ storage: entry["storage"].as_str().unwrap_or("").to_string(),
+ r#type: entry["type"].as_str().unwrap_or("").to_string(),
+ content: entry["content"].as_str().unwrap_or("").to_string(),
+ active: u32::from(available),
+ enabled: u32::from(available),
+ shared: entry["shared"]
+ .as_u64()
+ .map(|v| v as u32)
+ .or_else(|| entry["shared"].as_bool().map(u32::from))
+ .unwrap_or(0),
+ used: disk,
+ total: maxdisk,
+ avail: maxdisk.saturating_sub(disk),
+ node: entry["node"].as_str().unwrap_or("").to_string(),
+ });
+ }
+ Ok(storages)
}
pub async fn get_storage_content(
&self,
connection_id: &str,
storage: &str,
+ node: Option<&str>,
) -> crate::Result> {
let conn = self.connection(connection_id)?;
- // The content endpoint is node-scoped. Prefer the node configured on
- // the connection's primary endpoint; otherwise pick the first online
- // node from the cluster.
- let node = self.storage_node(connection_id).await?;
+ // The content endpoint is node-scoped. Prefer an explicitly requested
+ // node; otherwise use the node configured on the connection's primary
+ // endpoint, falling back to the first online node from the cluster.
+ let node = match node.filter(|n| !n.is_empty()) {
+ Some(node) => node.to_string(),
+ None => self.storage_node(connection_id).await?,
+ };
let path = format!("/nodes/{}/storage/{}/content", node, storage);
let data = conn.request(Method::GET, &path, &[], None).await?;
parse_api(&path, data)
@@ -1306,9 +1417,11 @@ impl ConnectionManager {
pub async fn get_tasks(&self, connection_id: &str) -> crate::Result> {
let conn = self.connection(connection_id)?;
- let query = [("limit", "50".to_string())];
+ // The server rejects `/cluster/tasks?limit=...` with HTTP 400 (`limit`
+ // is not in the endpoint schema); the default server-side limit of 50
+ // applies when no query is sent.
let data = conn
- .request(Method::GET, "/cluster/tasks", &query, None)
+ .request(Method::GET, "/cluster/tasks", &[], None)
.await?;
parse_api("/cluster/tasks", data)
}
@@ -1713,8 +1826,11 @@ impl ConnectionManager {
config: AddNICConfig,
) -> crate::Result<()> {
Self::validate_vm_type(vm_type)?;
+ // NIC model validation applies to QEMU guests only; LXC containers
+ // always use the veth device type and the model is ignored by the
+ // server (the frontend sends `veth` for containers).
const VALID_MODELS: [&str; 4] = ["virtio", "e1000", "rtl8139", "vmxnet3"];
- if !VALID_MODELS.contains(&config.model.as_str()) {
+ if vm_type != "lxc" && !VALID_MODELS.contains(&config.model.as_str()) {
return Err(Error::InvalidUrl(format!(
"Invalid NIC model '{}': expected one of virtio, e1000, rtl8139, vmxnet3",
config.model
@@ -1726,14 +1842,29 @@ impl ConnectionManager {
let data = conn.request(Method::GET, &path, &[], None).await?;
let index = first_free_bus_index(&data, "net");
let mac = config.macaddr.unwrap_or_else(|| "random".to_string());
- let mut value = format!("{}={}", config.model, mac);
- value.push_str(&format!(",bridge={}", config.bridge));
- if let Some(tag) = config.tag {
- value.push_str(&format!(",tag={}", tag));
- }
- if let Some(firewall) = config.firewall {
- value.push_str(&format!(",firewall={}", u32::from(firewall)));
- }
+ let value = if vm_type == "lxc" {
+ // LXC net values use the `name=ethN,type=veth,bridge=...` form;
+ // a user-supplied MAC (anything but `random`) is sent as
+ // `hwaddr`, and `firewall=1` is added when firewall is enabled.
+ let mut value = format!("name=eth{},type=veth,bridge={}", index, config.bridge);
+ if !mac.is_empty() && mac != "random" {
+ value.push_str(&format!(",hwaddr={}", mac));
+ }
+ if config.firewall.unwrap_or(false) {
+ value.push_str(",firewall=1");
+ }
+ value
+ } else {
+ let mut value = format!("{}={}", config.model, mac);
+ value.push_str(&format!(",bridge={}", config.bridge));
+ if let Some(tag) = config.tag {
+ value.push_str(&format!(",tag={}", tag));
+ }
+ if let Some(firewall) = config.firewall {
+ value.push_str(&format!(",firewall={}", u32::from(firewall)));
+ }
+ value
+ };
let key = format!("net{}", index);
let form = vec![(key.as_str(), value)];
conn.request(Method::POST, &path, &[], Some(&form)).await?;
@@ -1781,6 +1912,41 @@ impl ConnectionManager {
Ok(())
}
+ /// Updates a VM/container's basic configuration (`name`, `cores`, `memory`,
+ /// `description`) by POSTing a form containing only the present fields.
+ /// A config with every field `None` errors out instead of sending an empty
+ /// POST.
+ pub async fn update_vm_config(
+ &self,
+ connection_id: &str,
+ node: &str,
+ vmid: u32,
+ vm_type: &str,
+ config: UpdateVMConfig,
+ ) -> crate::Result<()> {
+ Self::validate_vm_type(vm_type)?;
+ let conn = self.connection(connection_id)?;
+ let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid);
+ let mut form: Vec<(&str, String)> = Vec::new();
+ if let Some(name) = config.name {
+ form.push(("name", name));
+ }
+ if let Some(cores) = config.cores {
+ form.push(("cores", cores.to_string()));
+ }
+ if let Some(memory) = config.memory {
+ form.push(("memory", memory.to_string()));
+ }
+ if let Some(description) = config.description {
+ form.push(("description", description));
+ }
+ if form.is_empty() {
+ return Err(Error::ApiError("Nothing to update".to_string()));
+ }
+ conn.request(Method::POST, &path, &[], Some(&form)).await?;
+ Ok(())
+ }
+
pub async fn get_snapshots(
&self,
connection_id: &str,
@@ -1827,9 +1993,10 @@ impl ConnectionManager {
) -> crate::Result<()> {
Self::validate_vm_type(vm_type)?;
let conn = self.connection(connection_id)?;
- // Snapshot names can contain URL-hostile characters, but Proxmox
- // expects the name inline in the path, so it is inserted verbatim.
- let path = format!("/nodes/{}/{}/{}/snapshot/{}", node, vm_type, vmid, name);
+ // Snapshot names can contain URL-hostile characters, so the name is
+ // percent-encoded into the path (everything but `A-Za-z0-9-._~`).
+ let encoded_name = utf8_percent_encode(name, SEGMENT_ENCODE_SET).to_string();
+ let path = format!("/nodes/{}/{}/{}/snapshot/{}", node, vm_type, vmid, encoded_name);
conn.request(Method::DELETE, &path, &[], None).await?;
Ok(())
}
@@ -1844,9 +2011,10 @@ impl ConnectionManager {
) -> crate::Result<()> {
Self::validate_vm_type(vm_type)?;
let conn = self.connection(connection_id)?;
+ let encoded_name = utf8_percent_encode(name, SEGMENT_ENCODE_SET).to_string();
let path = format!(
"/nodes/{}/{}/{}/snapshot/{}/rollback",
- node, vm_type, vmid, name
+ node, vm_type, vmid, encoded_name
);
conn.request(Method::POST, &path, &[], None).await?;
Ok(())
@@ -1962,15 +2130,56 @@ impl ConnectionManager {
connection_id: &str,
storage: Option<&str>,
) -> crate::Result> {
- let conn = self.connection(connection_id)?;
let node = self.storage_node(connection_id).await?;
- let storage = storage.unwrap_or("local");
+ // A specific storage is queried verbatim and its errors propagate.
+ // Without one, every enabled storage whose content list includes
+ // `backup` is queried and the results are aggregated; a storage whose
+ // content query fails during aggregation is skipped rather than
+ // failing the whole call.
+ match storage.map(str::trim).filter(|s| !s.is_empty()) {
+ Some(storage) => self.backup_entries(connection_id, &node, storage).await,
+ None => {
+ let storages = self.get_storage(connection_id).await?;
+ let mut seen = HashSet::new();
+ let mut names = Vec::new();
+ for storage in storages {
+ if storage.enabled == 0 {
+ continue;
+ }
+ let content_tokens: Vec<&str> =
+ storage.content.split(',').map(str::trim).collect();
+ if !content_tokens.contains(&"backup") {
+ continue;
+ }
+ if seen.insert(storage.storage.clone()) {
+ names.push(storage.storage);
+ }
+ }
+ let mut backups = Vec::new();
+ for name in names {
+ if let Ok(entries) = self.backup_entries(connection_id, &node, &name).await {
+ backups.extend(entries);
+ }
+ }
+ Ok(backups)
+ }
+ }
+ }
+
+ /// Fetches the backup entries of a single storage on `node` from the
+ /// node-scoped content endpoint. The content endpoint can mix backup
+ /// entries with iso/vztmpl entries, so the requested `content=backup`
+ /// filter is mirrored here and only entries tagged `backup` are mapped.
+ async fn backup_entries(
+ &self,
+ connection_id: &str,
+ node: &str,
+ storage: &str,
+ ) -> crate::Result> {
+ let conn = self.connection(connection_id)?;
let path = format!("/nodes/{}/storage/{}/content", node, storage);
let query = [("content", "backup".to_string())];
let data = conn.request(Method::GET, &path, &query, None).await?;
- // The content endpoint can mix backup entries with iso/vztmpl entries,
- // so the requested `content=backup` filter is mirrored here and only
- // entries tagged `backup` are mapped.
let entries: Vec = parse_api(&path, data)?;
let mut backups = Vec::new();
for entry in entries {
@@ -2183,15 +2392,19 @@ fn parse_disk_attrs(value: &str) -> (String, u64, String) {
(storage, size, format)
}
-/// True when `key` is a VM disk config key (`scsi0`, `virtio1`, `ide2`,
-/// `sata0`, `nvme0`, ...).
+/// True when `key` is a VM/container disk config key. QEMU guests use the
+/// `scsi0`/`virtio1`/`ide2`/`sata0`/`nvme0` bus keys; LXC containers use
+/// `rootfs` and mount-point keys `mp0`, `mp1`, ...
fn is_disk_key(key: &str) -> bool {
const BUSES: [&str; 5] = ["scsi", "virtio", "ide", "sata", "nvme"];
- BUSES.iter().any(|bus| {
+ let bus_key = |bus: &str| {
key.strip_prefix(bus).map_or(false, |suffix| {
!suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
})
- })
+ };
+ BUSES.iter().any(|bus| bus_key(bus))
+ || key == "rootfs"
+ || bus_key("mp")
}
/// True when `key` is a NIC config key (`net0`, `net1`, ...).
@@ -2215,8 +2428,13 @@ fn first_free_bus_index(config: &serde_json::Value, bus: &str) -> u32 {
}
/// The parsed pieces of a NIC config value such as
-/// `virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1`.
+/// `virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1` (QEMU) or
+/// `name=eth0,type=veth,hwaddr=BC:24:11:8D:DF:95,bridge=vmbr0,ip=dhcp` (LXC).
struct ParsedNetValue {
+ /// True when the value uses the LXC `name=eth0,...` format.
+ lxc: bool,
+ /// The interface name segment of an LXC value (`eth0`); empty for QEMU.
+ name: String,
model: String,
macaddr: String,
bridge: Option,
@@ -2225,10 +2443,16 @@ struct ParsedNetValue {
link_down: Option,
}
-/// Parses a NIC config value into its components. The first comma-separated
-/// segment is `model=macaddr`; the remaining segments are key=value
-/// attributes.
+/// Parses a NIC config value into its components.
+///
+/// QEMU values start with `model=macaddr` and continue with `key=value`
+/// attributes (`bridge`, `tag`, `firewall`, ...). LXC values start with
+/// `name=ethN` and express the device type and MAC as `type=veth` /
+/// `hwaddr=...` attributes, which are mapped onto `model` and `macaddr` so
+/// callers see a uniform shape.
fn parse_net_value(value: &str) -> ParsedNetValue {
+ let mut lxc = false;
+ let mut name = String::new();
let mut model = String::new();
let mut macaddr = String::new();
let mut bridge = None;
@@ -2238,9 +2462,14 @@ fn parse_net_value(value: &str) -> ParsedNetValue {
let mut segments = value.split(',');
if let Some(first) = segments.next() {
- if let Some((m, mac)) = first.split_once('=') {
- model = m.trim().to_string();
- macaddr = mac.trim().to_string();
+ if let Some((key, val)) = first.split_once('=') {
+ if key.trim() == "name" {
+ lxc = true;
+ name = val.trim().to_string();
+ } else {
+ model = key.trim().to_string();
+ macaddr = val.trim().to_string();
+ }
}
}
for segment in segments {
@@ -2251,12 +2480,19 @@ fn parse_net_value(value: &str) -> ParsedNetValue {
"tag" => tag = val.parse().ok(),
"firewall" => firewall = val.parse().ok(),
"link_down" => link_down = val.parse().ok(),
+ _ if lxc => match attr.trim() {
+ "type" => model = val.to_string(),
+ "hwaddr" => macaddr = val.to_string(),
+ _ => {}
+ },
_ => {}
}
}
}
ParsedNetValue {
+ lxc,
+ name,
model,
macaddr,
bridge,
@@ -2269,19 +2505,58 @@ fn parse_net_value(value: &str) -> ParsedNetValue {
/// Re-encodes a NIC config value after applying the edited fields. The model
/// and MAC address are always preserved; `config.bridge`, `config.tag` (a tag
/// of `0` removes the tag) and `config.firewall` replace the existing values
-/// when present.
+/// when present. Any other comma-separated `key=value` attribute in the
+/// current value (e.g. `link_down`, `rate`, `queues`, `disconnect`) is
+/// carried over verbatim, so editing a NIC does not drop attributes the
+/// client does not model.
+///
+/// LXC values are re-encoded in their native form — `name=ethN,type=...,
+/// hwaddr=...,bridge=...[,firewall=...]` — with the same unknown-attribute
+/// pass-through. LXC has no VLAN tag, so a tag is never emitted.
fn reencode_nic_value(current: &str, config: &EditNICConfig) -> String {
let parsed = parse_net_value(current);
let bridge = config.bridge.clone().or(parsed.bridge);
+ let firewall = match config.firewall {
+ Some(on) => Some(u32::from(on)),
+ None => parsed.firewall,
+ };
+
+ if parsed.lxc {
+ let mut value = format!(
+ "name={},type={},hwaddr={}",
+ parsed.name, parsed.model, parsed.macaddr
+ );
+ if let Some(bridge) = bridge {
+ value.push_str(&format!(",bridge={}", bridge));
+ }
+ if let Some(firewall) = firewall {
+ value.push_str(&format!(",firewall={}", firewall));
+ }
+ // Carry over every other `key=value` attribute from the current value
+ // verbatim, skipping the leading `name=` segment and the attributes
+ // already re-emitted above (`tag` is never valid for LXC).
+ let mut segments = current.split(',');
+ segments.next();
+ for segment in segments {
+ if let Some((attr, _)) = segment.split_once('=') {
+ if matches!(
+ attr.trim(),
+ "bridge" | "firewall" | "name" | "type" | "hwaddr" | "tag"
+ ) {
+ continue;
+ }
+ value.push(',');
+ value.push_str(segment.trim());
+ }
+ }
+ return value;
+ }
+
let tag = match config.tag {
Some(0) => None,
Some(t) => Some(t),
None => parsed.tag.filter(|&t| t != 0),
};
- let firewall = match config.firewall {
- Some(on) => Some(u32::from(on)),
- None => parsed.firewall,
- };
let mut value = format!("{}={}", parsed.model, parsed.macaddr);
if let Some(bridge) = bridge {
@@ -2293,5 +2568,19 @@ fn reencode_nic_value(current: &str, config: &EditNICConfig) -> String {
if let Some(firewall) = firewall {
value.push_str(&format!(",firewall={}", firewall));
}
+ // Carry over every other `key=value` attribute from the current value
+ // verbatim, skipping the leading model=macaddr segment and the attributes
+ // already re-emitted above.
+ let mut segments = current.split(',');
+ segments.next();
+ for segment in segments {
+ if let Some((attr, _)) = segment.split_once('=') {
+ if matches!(attr.trim(), "bridge" | "tag" | "firewall") {
+ continue;
+ }
+ value.push(',');
+ value.push_str(segment.trim());
+ }
+ }
value
}
diff --git a/src-tauri/src/console_proxy.rs b/src-tauri/src/console_proxy.rs
new file mode 100644
index 0000000..4b7150c
--- /dev/null
+++ b/src-tauri/src/console_proxy.rs
@@ -0,0 +1,224 @@
+//! Local WebSocket proxy for VM and container consoles.
+//!
+//! The webview cannot open a `wss://` connection to a self-signed Proxmox
+//! server (the browser validates the certificate against the system trust
+//! store, which rejects it). The consoles therefore go through a loopback
+//! proxy: the backend opens the `wss://` connection to Proxmox (using the same
+//! accept-self-signed transport as the HTTP API) and forwards frames to a
+//! plain `ws://127.0.0.1:` endpoint that the webview can reach. A random
+//! per-session token in the query string keeps other local processes from
+//! hijacking an open console.
+
+use crate::connection::ConnectionManager;
+use crate::websocket::connect_ws;
+use crate::{Error, Result};
+use futures_util::{SinkExt, StreamExt};
+use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use tokio::net::TcpListener;
+use tokio::sync::watch;
+use tokio::time::{timeout, Duration};
+use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
+use tokio_tungstenite::tungstenite::http::StatusCode;
+use tokio_tungstenite::tungstenite::Message;
+use uuid::Uuid;
+
+/// Characters left unencoded when embedding a proxy ticket into a query
+/// string (everything but RFC 3986 unreserved characters is encoded).
+const QUERY_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
+ .remove(b'-')
+ .remove(b'.')
+ .remove(b'_')
+ .remove(b'~');
+
+/// The local endpoint handed to the frontend for one console session.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConsoleProxyInfo {
+ pub session_id: String,
+ pub url: String,
+}
+
+struct ConsoleSession {
+ shutdown: watch::Sender<()>,
+}
+
+pub struct ConsoleProxyManager {
+ sessions: HashMap,
+}
+
+impl ConsoleProxyManager {
+ pub fn new() -> Self {
+ Self {
+ sessions: HashMap::new(),
+ }
+ }
+
+ /// Opens a console proxy for a guest and returns a local `ws://` URL.
+ ///
+ /// `kind` is `"vnc"` (QEMU) or `"term"` (LXC). A session handles a single
+ /// local client; when the client disconnects the Proxmox stream is torn
+ /// down. Call [`ConsoleProxyManager::stop`] to cancel a session that is
+ /// still waiting for its client.
+ pub async fn start(
+ &mut self,
+ connection_id: &str,
+ kind: &str,
+ node: &str,
+ vmid: u32,
+ manager: &ConnectionManager,
+ ) -> Result {
+ let (proxy_port, ticket) = match kind {
+ "vnc" => {
+ let proxy = manager.create_vnc_proxy(connection_id, node, vmid).await?;
+ (proxy.port, proxy.ticket)
+ }
+ "term" => {
+ let proxy = manager.create_term_proxy(connection_id, node, vmid).await?;
+ (proxy.port, proxy.ticket)
+ }
+ other => {
+ return Err(Error::InvalidUrl(format!(
+ "Invalid console type '{}': expected 'vnc' or 'term'",
+ other
+ )));
+ }
+ };
+
+ let origin = manager.get_websocket_url(connection_id, node).await?;
+ let encoded_ticket = utf8_percent_encode(&ticket, QUERY_ENCODE_SET).to_string();
+ let path = if kind == "vnc" {
+ format!(
+ "/api2/json/nodes/{}/qemu/{}/vncwebsocket?port={}&vncticket={}",
+ node, vmid, proxy_port, encoded_ticket
+ )
+ } else {
+ format!(
+ "/api2/json/nodes/{}/lxc/{}/proxy?port={}&ticket={}",
+ node, vmid, proxy_port, encoded_ticket
+ )
+ };
+ 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))
+ .await
+ .map_err(|_| Error::WebSocketError("Timed out opening console proxy".to_string()))?
+ .map_err(|e| Error::WebSocketError(format!("Cannot open console proxy: {}", e)))?;
+
+ let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
+ .await
+ .map_err(|e| Error::WebSocketError(format!("Cannot bind console proxy: {}", e)))?;
+ let local_port = listener
+ .local_addr()
+ .map_err(|e| Error::WebSocketError(format!("Cannot read console proxy port: {}", e)))?
+ .port();
+
+ let session_id = Uuid::new_v4().to_string();
+ let token = Uuid::new_v4().to_string();
+ let (shutdown_tx, shutdown_rx) = watch::channel(());
+
+ let task_token = token.clone();
+ tokio::spawn(async move {
+ let mut shutdown = shutdown_rx;
+ // Wait for the single local client (or a stop request).
+ let client = tokio::select! {
+ _ = shutdown.changed() => None,
+ accepted = listener.accept() => match accepted {
+ Ok((tcp, _)) => {
+ let token = task_token.clone();
+ match tokio_tungstenite::accept_hdr_async(tcp, move |request: &Request, response: Response| -> std::result::Result {
+ let query = request.uri().query().unwrap_or("");
+ if query == format!("token={}", token) {
+ Ok(response)
+ } else {
+ Err(Response::builder()
+ .status(StatusCode::FORBIDDEN)
+ .body(None)
+ .expect("valid HTTP response"))
+ }
+ })
+ .await
+ {
+ Ok(client) => Some(client),
+ Err(_) => None,
+ }
+ }
+ Err(_) => None,
+ },
+ };
+
+ if let Some(client) = client {
+ relay(client, server, &mut shutdown).await;
+ }
+ });
+
+ self.sessions.insert(
+ session_id.clone(),
+ ConsoleSession {
+ shutdown: shutdown_tx,
+ },
+ );
+
+ Ok(ConsoleProxyInfo {
+ session_id,
+ url: format!("ws://127.0.0.1:{}/?token={}", local_port, token),
+ })
+ }
+
+ /// Cancels a console session. Sessions whose client already connected and
+ /// disconnected are gone from the map, so a stop for a stale id is a
+ /// no-op.
+ pub async fn stop(&mut self, session_id: &str) -> Result<()> {
+ if let Some(session) = self.sessions.remove(session_id) {
+ let _ = session.shutdown.send(());
+ }
+ Ok(())
+ }
+}
+
+/// Bidirectionally copies WebSocket messages between the local client and the
+/// Proxmox console stream until either side closes or the session is stopped.
+async fn relay(
+ client: tokio_tungstenite::WebSocketStream,
+ server: tokio_tungstenite::WebSocketStream<
+ tokio_tungstenite::MaybeTlsStream,
+ >,
+ shutdown: &mut watch::Receiver<()>,
+) {
+ let (mut client_tx, mut client_rx) = client.split();
+ let (mut server_tx, mut server_rx) = server.split();
+
+ loop {
+ tokio::select! {
+ _ = shutdown.changed() => break,
+ next = client_rx.next() => match next {
+ Some(Ok(msg)) => {
+ let closing = matches!(msg, Message::Close(_));
+ if server_tx.send(msg).await.is_err() {
+ break;
+ }
+ if closing {
+ break;
+ }
+ }
+ _ => break,
+ },
+ next = server_rx.next() => match next {
+ Some(Ok(msg)) => {
+ let closing = matches!(msg, Message::Close(_));
+ if client_tx.send(msg).await.is_err() {
+ break;
+ }
+ if closing {
+ break;
+ }
+ }
+ _ => break,
+ },
+ }
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index a1759ab..6c46074 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -3,10 +3,11 @@ use std::path::PathBuf;
use std::sync::Arc;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
-use tauri::Manager;
+use tauri::{Emitter, Manager};
use tokio::sync::RwLock;
mod connection;
+mod console_proxy;
mod error;
mod proxmox;
pub mod tls;
@@ -15,10 +16,11 @@ mod websocket;
pub use connection::{
api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult,
};
+pub use console_proxy::ConsoleProxyInfo;
pub use error::Error;
pub use proxmox::{
AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig,
- RestoreConfig,
+ RestoreConfig, UpdateVMConfig,
};
use websocket::WebSocketManager;
@@ -125,6 +127,7 @@ pub struct CertificateInfo {
struct AppState {
connection_manager: Arc>,
ws_manager: Arc>,
+ console_proxy: Arc>,
}
/// Returns the path of the persisted connections file.
@@ -269,9 +272,12 @@ async fn get_storage_content(
state: tauri::State<'_, AppState>,
connection_id: String,
storage: String,
+ node: Option,
) -> Result> {
let manager = state.connection_manager.read().await;
- manager.get_storage_content(&connection_id, &storage).await
+ manager
+ .get_storage_content(&connection_id, &storage, node.as_deref())
+ .await
}
#[tauri::command]
@@ -598,6 +604,21 @@ async fn migrate_vm(
.await
}
+#[tauri::command]
+async fn update_vm_config(
+ state: tauri::State<'_, AppState>,
+ connection_id: String,
+ node: String,
+ vmid: u32,
+ vm_type: String,
+ config: proxmox::UpdateVMConfig,
+) -> Result<()> {
+ let manager = state.connection_manager.read().await;
+ manager
+ .update_vm_config(&connection_id, &node, vmid, &vm_type, config)
+ .await
+}
+
// Authentication commands
#[tauri::command]
async fn login_with_password(
@@ -714,6 +735,31 @@ async fn is_websocket_connected(
Ok(ws_manager.is_connected(&connection_id))
}
+// Console proxy commands
+#[tauri::command]
+async fn start_console_proxy(
+ state: tauri::State<'_, AppState>,
+ connection_id: String,
+ kind: String,
+ node: String,
+ vmid: u32,
+) -> Result {
+ let manager = state.connection_manager.read().await;
+ let mut proxy = state.console_proxy.write().await;
+ proxy
+ .start(&connection_id, &kind, &node, vmid, &manager)
+ .await
+}
+
+#[tauri::command]
+async fn stop_console_proxy(
+ state: tauri::State<'_, AppState>,
+ session_id: String,
+) -> Result<()> {
+ let mut proxy = state.console_proxy.write().await;
+ proxy.stop(&session_id).await
+}
+
// Backup management commands
#[tauri::command]
async fn get_backup_jobs(
@@ -862,10 +908,10 @@ async fn update_tray_menu(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
- .plugin(tauri_plugin_shell::init())
.manage(AppState {
connection_manager: Arc::new(RwLock::new(ConnectionManager::new())),
ws_manager: Arc::new(RwLock::new(WebSocketManager::new())),
+ console_proxy: Arc::new(RwLock::new(console_proxy::ConsoleProxyManager::new())),
})
.invoke_handler(tauri::generate_handler![
load_connections,
@@ -909,12 +955,15 @@ pub fn run() {
delete_snapshot,
rollback_snapshot,
migrate_vm,
+ update_vm_config,
create_vnc_proxy,
create_term_proxy,
get_websocket_url,
connect_websocket,
disconnect_websocket,
is_websocket_connected,
+ start_console_proxy,
+ stop_console_proxy,
get_backup_jobs,
get_backups,
create_backup_job,
@@ -956,6 +1005,15 @@ pub fn run() {
"quit" => {
app.exit(0);
}
+ // Connection entries are dynamic menu items added by the
+ // frontend via `update_tray_menu`. Clicking one switches
+ // the active connection there, so forward the id as an
+ // event instead of reaching into the backend state here.
+ id if id.starts_with("connection_") => {
+ if let Some(connection_id) = id.strip_prefix("connection_") {
+ let _ = app.emit("tray-connection-click", connection_id.to_string());
+ }
+ }
_ => {}
})
.build(app)?;
diff --git a/src-tauri/src/proxmox.rs b/src-tauri/src/proxmox.rs
index 44030dc..b7f6a1e 100644
--- a/src-tauri/src/proxmox.rs
+++ b/src-tauri/src/proxmox.rs
@@ -26,15 +26,25 @@ pub struct Node {
pub node: String,
#[serde(default)]
pub status: String,
+ #[serde(default)]
pub cpu: f64,
+ #[serde(default)]
pub maxcpu: u32,
+ #[serde(default)]
pub mem: u64,
+ #[serde(default)]
pub maxmem: u64,
+ #[serde(default)]
pub disk: u64,
+ #[serde(default)]
pub maxdisk: u64,
+ #[serde(default)]
pub uptime: u64,
+ #[serde(default)]
pub level: String,
+ #[serde(default)]
pub id: String,
+ #[serde(default)]
pub r#type: String,
}
@@ -47,6 +57,7 @@ pub struct VM {
pub name: Option,
#[serde(default)]
pub status: String,
+ #[serde(default)]
pub r#type: String,
#[serde(default)]
pub node: String,
@@ -85,8 +96,11 @@ pub struct VM {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Storage {
+ #[serde(default)]
pub storage: String,
+ #[serde(default)]
pub r#type: String,
+ #[serde(default)]
pub content: String,
#[serde(default)]
pub active: u32,
@@ -107,16 +121,23 @@ pub struct Storage {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Task {
+ #[serde(default)]
pub upid: String,
#[serde(default)]
pub node: String,
+ #[serde(default)]
pub pid: u32,
+ #[serde(default)]
pub pstart: u64,
+ #[serde(default)]
pub starttime: u64,
#[serde(default)]
pub endtime: Option,
+ #[serde(default)]
pub r#type: String,
+ #[serde(default)]
pub id: String,
+ #[serde(default)]
pub user: String,
#[serde(default)]
pub status: Option,
@@ -136,20 +157,28 @@ pub struct ClusterStatus {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClusterNode {
+ #[serde(default)]
pub name: String,
+ #[serde(default)]
pub nodeid: u32,
+ #[serde(default)]
pub online: u32,
+ #[serde(default)]
pub local: Option,
+ #[serde(default)]
pub ip: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
+ #[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
+ #[serde(default)]
pub snaptime: u64,
+ #[serde(default)]
pub vmstate: u32,
#[serde(default)]
pub parent: Option,
@@ -163,17 +192,14 @@ pub struct CreateSnapshotConfig {
pub vmstate: Option,
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct ApiResponse {
- pub data: T,
-}
-
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkInterface {
+ #[serde(default)]
pub name: String,
+ #[serde(default)]
pub model: String,
+ #[serde(default)]
pub macaddr: String,
#[serde(default)]
pub bridge: Option,
@@ -204,10 +230,22 @@ pub struct EditNICConfig {
pub firewall: Option,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateVMConfig {
+ pub name: Option,
+ pub cores: Option,
+ /// Memory size in MiB.
+ pub memory: Option,
+ pub description: Option,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Backup {
+ #[serde(default)]
pub volid: String,
+ #[serde(default)]
pub backupid: String,
#[serde(rename = "backup-type")]
pub backup_type: String,
@@ -215,8 +253,11 @@ pub struct Backup {
pub backup_id: String,
#[serde(rename = "backup-time")]
pub backup_time: u64,
+ #[serde(default)]
pub storage: String,
+ #[serde(default)]
pub size: u64,
+ #[serde(default)]
pub ctime: u64,
}
@@ -224,9 +265,15 @@ pub struct Backup {
#[serde(rename_all = "camelCase")]
pub struct BackupJob {
pub id: String,
+ /// The server sends `storage` (not `store`).
+ #[serde(rename = "storage")]
pub store: String,
pub schedule: String,
+ /// `all` is only present on jobs that back up every guest; jobs with an
+ /// explicit `vmid` selection omit it.
+ #[serde(default)]
pub all: u32,
+ #[serde(default)]
pub enabled: u32,
#[serde(default)]
pub node: Option,
@@ -266,7 +313,9 @@ pub struct RestoreConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageContent {
+ #[serde(default)]
pub content: String,
+ #[serde(default)]
pub ctime: u64,
#[serde(default)]
pub format: Option,
@@ -274,12 +323,14 @@ pub struct StorageContent {
pub size: Option,
#[serde(default)]
pub subtype: Option,
+ #[serde(default)]
pub volid: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageDetail {
+ #[serde(default)]
pub storage: String,
pub r#type: String,
pub content: String,
diff --git a/src-tauri/src/tls.rs b/src-tauri/src/tls.rs
index f9c3957..7dcc007 100644
--- a/src-tauri/src/tls.rs
+++ b/src-tauri/src/tls.rs
@@ -17,6 +17,7 @@ use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio::net::TcpStream;
+use tokio::time::{timeout, Duration};
use tokio_rustls::TlsConnector;
use url::Url;
use x509_cert::der::Decode;
@@ -27,7 +28,7 @@ use x509_cert::der::Decode;
/// certificate's SHA-256 fingerprint is captured on each connection and
/// compared against the pinned value recorded on first use (TOFU).
#[derive(Debug)]
-struct AcceptAllVerifier;
+pub(crate) struct AcceptAllVerifier;
impl ServerCertVerifier for AcceptAllVerifier {
fn verify_server_cert(
@@ -70,7 +71,7 @@ impl ServerCertVerifier for AcceptAllVerifier {
///
/// `install_default` is idempotent and thread-safe; subsequent calls return
/// `Err` with the already-installed provider, which is ignored here.
-fn ensure_crypto_provider() {
+pub(crate) fn ensure_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
@@ -109,8 +110,9 @@ async fn capture_leaf_certificate_der(url: &str) -> crate::Result> {
.first()
.ok_or_else(|| Error::InvalidUrl(format!("Cannot resolve host '{}'", addr)))?;
- let tcp = TcpStream::connect(address)
+ let tcp = timeout(Duration::from_secs(10), TcpStream::connect(address))
.await
+ .map_err(|_| Error::CertificateError(format!("Timed out connecting to '{}'", addr)))?
.map_err(|e| Error::CertificateError(format!("Cannot connect to '{}': {}", addr, e)))?;
let config = ClientConfig::builder()
@@ -119,9 +121,10 @@ async fn capture_leaf_certificate_der(url: &str) -> crate::Result> {
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
- let tls = connector.connect(server_name, tcp).await.map_err(|e| {
- Error::CertificateError(format!("TLS handshake with '{}' failed: {}", addr, e))
- })?;
+ let tls = timeout(Duration::from_secs(15), connector.connect(server_name, tcp))
+ .await
+ .map_err(|_| Error::CertificateError(format!("Timed out during TLS handshake with '{}'", addr)))?
+ .map_err(|e| Error::CertificateError(format!("TLS handshake with '{}' failed: {}", addr, e)))?;
let (_, session) = tls.get_ref();
let leaf = session
diff --git a/src-tauri/src/websocket.rs b/src-tauri/src/websocket.rs
index 4ee3a9e..a31ae70 100644
--- a/src-tauri/src/websocket.rs
+++ b/src-tauri/src/websocket.rs
@@ -1,14 +1,52 @@
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
+use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::mpsc;
-use tokio::time::{sleep, Duration};
-use tokio_tungstenite::connect_async;
+use tokio::time::{sleep, timeout, Duration};
+use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message;
+use tokio_tungstenite::Connector;
use crate::error::Error;
+/// Connects to a Proxmox WebSocket URL, accepting self-signed certificates.
+///
+/// The regular `wss://` flow validates against the system trust store, which
+/// 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.
+pub async fn connect_ws(
+ url: &str,
+) -> crate::Result>>
+{
+ let request = url
+ .into_client_request()
+ .map_err(|e| Error::WebSocketError(e.to_string()))?;
+ let is_wss = request.uri().scheme_str() == Some("wss");
+
+ let connector = if is_wss {
+ crate::tls::ensure_crypto_provider();
+ let config = rustls::ClientConfig::builder()
+ .dangerous()
+ .with_custom_certificate_verifier(Arc::new(crate::tls::AcceptAllVerifier))
+ .with_no_client_auth();
+ Some(Connector::Rustls(Arc::new(config)))
+ } else {
+ None
+ };
+
+ let (ws, _) = timeout(
+ Duration::from_secs(15),
+ tokio_tungstenite::connect_async_tls_with_config(request, None, false, connector),
+ )
+ .await
+ .map_err(|_| Error::WebSocketError(format!("Timed out connecting to '{}'", url)))?
+ .map_err(|e| Error::WebSocketError(e.to_string()))?;
+ Ok(ws)
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskUpdate {
pub connection_id: String,
@@ -60,7 +98,7 @@ impl WebSocketManager {
app_handle: tauri::AppHandle,
) -> crate::Result<()> {
// Disconnect any existing connection for this ID
- self.disconnect(&connection_id).await;
+ let _ = self.disconnect(&connection_id).await;
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
@@ -134,9 +172,7 @@ async fn connect_and_run(
url: &str,
app_handle: &tauri::AppHandle,
) -> crate::Result<()> {
- let (ws_stream, _) = connect_async(url)
- .await
- .map_err(|e| Error::WebSocketError(e.to_string()))?;
+ let ws_stream = connect_ws(url).await?;
let cid = connection_id.to_string();
let (mut write, mut read) = ws_stream.split();
diff --git a/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d b/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d
index a16f66d..7a71209 100644
--- a/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d
+++ b/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d
@@ -1,5 +1,5 @@
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs:
diff --git a/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d b/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d
index 02b6234..fea6f37 100644
--- a/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d
+++ b/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d
@@ -1,5 +1,5 @@
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs:
diff --git a/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d b/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d
index dd61a58..33b37de 100644
--- a/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d
+++ b/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d
@@ -1,5 +1,5 @@
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs:
diff --git a/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d b/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d
index 2f3192a..aa5c3ea 100644
--- a/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d
+++ b/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d
@@ -1,5 +1,5 @@
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs:
diff --git a/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d b/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d
index ffed241..fd1b76f 100644
--- a/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d
+++ b/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d
@@ -1,8 +1,8 @@
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rlib: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rlib: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
-/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rmeta: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
+/home/user/projects/dev/clustri/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rmeta: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs:
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs:
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index d13a04e..27e29d4 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -20,7 +20,8 @@
}
],
"security": {
- "csp": null
+ "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost http://tauri.localhost https://tauri.localhost ws://127.0.0.1:* http://localhost:* https: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
+ "devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost http://tauri.localhost https://tauri.localhost ws://127.0.0.1:* http://localhost:* ws://localhost:* https: wss:; object-src 'none'; base-uri 'self'"
}
},
"bundle": {
diff --git a/src-tauri/tests/api_client.rs b/src-tauri/tests/api_client.rs
index d2444ab..ef0e2ee 100644
--- a/src-tauri/tests/api_client.rs
+++ b/src-tauri/tests/api_client.rs
@@ -127,7 +127,7 @@ async fn server_error_message_is_mapped_to_api_error() {
when.method(GET).path("/api2/json/version");
then.status(500)
.header("content-type", "application/json")
- .body(r#"{"message":"boom"}"#);
+ .body(r#"{"errors":"specific failure","message":"boom"}"#);
});
let error = api_request(
@@ -142,9 +142,48 @@ async fn server_error_message_is_mapped_to_api_error() {
.await
.expect_err("request should fail");
+ // A string `errors` field takes precedence over the generic `message`.
assert!(
- matches!(error, Error::ApiError(ref message) if message == "boom"),
- "expected ApiError with message 'boom', got: {}",
+ matches!(error, Error::ApiError(ref message) if message == "specific failure"),
+ "expected ApiError with message 'specific failure', got: {}",
+ error
+ );
+ mock.assert();
+}
+
+#[tokio::test]
+async fn server_error_errors_object_first_pair_is_surfaced() {
+ let server = MockServer::start();
+ let mock = server.mock(|when, then| {
+ when.method(GET).path("/api2/json/version");
+ then.status(400)
+ .header("content-type", "application/json")
+ .body(
+ r#"{"errors":{"limit":"property is not defined in schema"},"message":"Parameter verification failed."}"#,
+ );
+ });
+
+ let error = api_request(
+ &Client::new(),
+ &server.base_url(),
+ RMethod::GET,
+ "/version",
+ &token_auth(),
+ &[],
+ None,
+ )
+ .await
+ .expect_err("request should fail");
+
+ // An `errors` object surfaces its first `key: value` pair, not the generic
+ // `message` fallback.
+ assert!(
+ matches!(
+ error,
+ Error::ApiError(ref message)
+ if message == "limit: property is not defined in schema"
+ ),
+ "expected ApiError with 'limit: property is not defined in schema', got: {}",
error
);
mock.assert();
diff --git a/src-tauri/tests/api_commands.rs b/src-tauri/tests/api_commands.rs
index 6ae7ec4..ad6dbe8 100644
--- a/src-tauri/tests/api_commands.rs
+++ b/src-tauri/tests/api_commands.rs
@@ -8,7 +8,7 @@
//! called directly on an added connection without `connect()`.
use httpmock::prelude::*;
-use clustri::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
+use clustri::{ConnectionConfig, ConnectionManager, EndpointConfig, Error, UpdateVMConfig};
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint points at the mock server. `node` pins the storage node.
@@ -168,14 +168,17 @@ async fn get_storage_maps_cluster_resources() {
.body(
serde_json::json!({
"data": [
+ // The real cluster-resource shape: usage is `disk`/`maxdisk`
+ // and liveness is the `status` string. There are no
+ // `used`/`total`/`avail`/`enabled`/`active` keys.
{"storage": "local", "node": "pve1", "type": "dir",
- "content": "iso,vztmpl", "enabled": 1, "shared": 0, "active": 1,
- "total": 858993459200u64, "used": 429496729600u64, "avail": 429496729600u64,
+ "content": "iso,vztmpl", "shared": 0, "plugintype": "dir",
+ "disk": 429496729600u64, "maxdisk": 858993459200u64,
"status": "available"},
- {"storage": "backup", "node": "pve1", "type": "nfs",
- "content": "backup", "enabled": 1, "shared": 1, "active": 1,
- "total": 1717986918400u64, "used": 644245094400u64,
- "avail": 1073741824000u64, "status": "available"}
+ {"storage": "backup", "node": "pve2", "type": "nfs",
+ "content": "backup", "shared": 1, "plugintype": "nfs",
+ "disk": 644245094400u64, "maxdisk": 1717986918400u64,
+ "status": "unavailable"}
]
})
.to_string(),
@@ -192,14 +195,22 @@ async fn get_storage_maps_cluster_resources() {
assert_eq!(storages[0].storage, "local");
assert_eq!(storages[0].r#type, "dir");
assert_eq!(storages[0].content, "iso,vztmpl");
- assert_eq!(storages[0].active, 1);
- assert_eq!(storages[0].enabled, 1);
- assert_eq!(storages[0].shared, 0);
+ // `disk`/`maxdisk`/`status` map onto used/total/avail/enabled/active.
assert_eq!(storages[0].used, 429496729600u64);
assert_eq!(storages[0].total, 858993459200u64);
assert_eq!(storages[0].avail, 429496729600u64);
+ assert_eq!(storages[0].enabled, 1);
+ assert_eq!(storages[0].active, 1);
+ assert_eq!(storages[0].shared, 0);
assert_eq!(storages[0].node, "pve1");
+ // A storage whose status is not "available" reports enabled/active 0.
+ assert_eq!(storages[1].enabled, 0);
+ assert_eq!(storages[1].active, 0);
+ assert_eq!(storages[1].used, 644245094400u64);
+ assert_eq!(storages[1].total, 1717986918400u64);
+ assert_eq!(storages[1].avail, 1073741824000u64);
assert_eq!(storages[1].shared, 1);
+ assert_eq!(storages[1].node, "pve2");
mock.assert();
}
@@ -227,7 +238,7 @@ async fn get_storage_content_uses_configured_node() {
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
let contents = manager
- .get_storage_content("conn", "local")
+ .get_storage_content("conn", "local", Some("pve1"))
.await
.expect("content should be fetched");
@@ -282,7 +293,7 @@ async fn get_storage_content_falls_back_to_online_node() {
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let contents = manager
- .get_storage_content("conn", "local")
+ .get_storage_content("conn", "local", None)
.await
.expect("content should be fetched");
@@ -292,6 +303,36 @@ async fn get_storage_content_falls_back_to_online_node() {
content_mock.assert();
}
+#[tokio::test]
+async fn get_storage_content_uses_explicit_node() {
+ let server = MockServer::start();
+ let token = "root@pam!explicit-node-token";
+ let mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve2/storage/local/content");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": [{"volid": "local:iso/explicit.iso", "content": "iso",
+ "ctime": 1700000000}]
+ })
+ .to_string(),
+ );
+ });
+
+ // The connection pins `pve1`, but an explicitly requested node must win.
+ let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
+ let contents = manager
+ .get_storage_content("conn", "local", Some("pve2"))
+ .await
+ .expect("content should be fetched");
+
+ assert_eq!(contents.len(), 1);
+ assert_eq!(contents[0].volid, "local:iso/explicit.iso");
+ mock.assert();
+}
+
#[tokio::test]
async fn get_storage_detail_maps_status() {
let server = MockServer::start();
@@ -303,9 +344,11 @@ async fn get_storage_detail_maps_status() {
.header("content-type", "application/json")
.body(
serde_json::json!({
- "data": {"storage": "local", "type": "dir", "content": "iso,vztmpl",
+ // The real status response has no `storage` or `node` keys;
+ // both fields must default to empty strings.
+ "data": {"type": "dir", "content": "iso,vztmpl",
"active": 1, "enabled": 1, "shared": 0, "used": 429496729600u64,
- "total": 858993459200u64, "avail": 429496729600u64, "node": "pve1"}
+ "total": 858993459200u64, "avail": 429496729600u64}
})
.to_string(),
);
@@ -317,7 +360,10 @@ async fn get_storage_detail_maps_status() {
.await
.expect("detail should be fetched");
- assert_eq!(detail.storage, "local");
+ // The storage and node are not part of the status response, so they
+ // default to empty strings.
+ assert_eq!(detail.storage, "");
+ assert_eq!(detail.node, "");
assert_eq!(detail.r#type, "dir");
assert_eq!(detail.content, "iso,vztmpl");
assert_eq!(detail.active, 1);
@@ -325,7 +371,6 @@ async fn get_storage_detail_maps_status() {
assert_eq!(detail.used, 429496729600u64);
assert_eq!(detail.total, 858993459200u64);
assert_eq!(detail.avail, 429496729600u64);
- assert_eq!(detail.node, "pve1");
mock.assert();
}
@@ -335,8 +380,7 @@ async fn get_tasks_maps_cluster_tasks() {
let token = "root@pam!tasks-token";
let mock = server.mock(|when, then| {
when.method(GET)
- .path("/api2/json/cluster/tasks")
- .query_param("limit", "50");
+ .path("/api2/json/cluster/tasks");
then.status(200)
.header("content-type", "application/json")
.body(
@@ -527,12 +571,90 @@ async fn lifecycle_with_invalid_type_errors() {
error
);
assert_eq!(
- probe.hits(),
+ probe.calls(),
0,
"no HTTP request should be made for an invalid vm type"
);
}
+#[tokio::test]
+async fn update_vm_config_posts_only_present_fields() {
+ let server = MockServer::start();
+ let token = "root@pam!update-config-token";
+ let mock = server.mock(|when, then| {
+ when.method(POST)
+ .path("/api2/json/nodes/pve1/qemu/100/config")
+ .header("Authorization", format!("PVEAPIToken={}", token))
+ // The form body carries exactly the present fields.
+ .body_includes("name=web01")
+ .body_includes("cores=4")
+ .body_includes("memory=8192")
+ .body_excludes("description");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(r#"{"data":null}"#);
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
+ manager
+ .update_vm_config(
+ "conn",
+ "pve1",
+ 100,
+ "qemu",
+ UpdateVMConfig {
+ name: Some("web01".to_string()),
+ cores: Some(4),
+ memory: Some(8192),
+ description: None,
+ },
+ )
+ .await
+ .expect("config update should succeed");
+
+ mock.assert();
+}
+
+#[tokio::test]
+async fn update_vm_config_with_all_none_errors_without_request() {
+ let server = MockServer::start();
+ let probe = server.mock(|when, then| {
+ when.method(POST)
+ .path("/api2/json/nodes/pve1/qemu/100/config");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(r#"{"data":null}"#);
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!empty-config-token", None).await;
+ let error = manager
+ .update_vm_config(
+ "conn",
+ "pve1",
+ 100,
+ "qemu",
+ UpdateVMConfig {
+ name: None,
+ cores: None,
+ memory: None,
+ description: None,
+ },
+ )
+ .await
+ .expect_err("an empty config must be rejected");
+
+ assert!(
+ matches!(error, Error::ApiError(ref message) if message == "Nothing to update"),
+ "expected ApiError 'Nothing to update', got: {}",
+ error
+ );
+ assert_eq!(
+ probe.calls(),
+ 0,
+ "no HTTP request should be made for an empty config"
+ );
+}
+
#[tokio::test]
async fn read_methods_error_with_connection_not_found() {
let server = MockServer::start();
diff --git a/src-tauri/tests/api_disk_network.rs b/src-tauri/tests/api_disk_network.rs
index bd58e79..8dcdf5f 100644
--- a/src-tauri/tests/api_disk_network.rs
+++ b/src-tauri/tests/api_disk_network.rs
@@ -334,6 +334,50 @@ async fn edit_nic_preserves_model_and_mac() {
post_mock.assert();
}
+#[tokio::test]
+async fn edit_nic_tag_clear_preserves_other_attributes() {
+ let server = MockServer::start();
+ let token = "root@pam!edit-nic-tag-token";
+ let get_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/qemu/100/config");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": {"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=10,link_down=1"}
+ })
+ .to_string(),
+ );
+ });
+ let post_mock = server.mock(|when, then| {
+ when.method(POST)
+ .path("/api2/json/nodes/pve1/qemu/100/config")
+ // urlencoded: `=` and `,` become `%3D` and `%2C`; the tag is gone
+ // while the unmodeled `link_down` attribute is carried over.
+ .body_includes("net0=virtio%3DAA%3ABB%3ACC%3ADD%3AEE%3AFF%2Cbridge%3Dvmbr0%2Clink_down%3D1")
+ .body_excludes("tag");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(r#"{"data":null}"#);
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
+ let config = EditNICConfig {
+ bridge: None,
+ model: None,
+ tag: Some(0),
+ firewall: None,
+ };
+ manager
+ .edit_nic("conn", "pve1", 100, "qemu", "net0", config)
+ .await
+ .expect("nic should be edited");
+
+ get_mock.assert();
+ post_mock.assert();
+}
+
#[tokio::test]
async fn remove_nic_posts_delete() {
let server = MockServer::start();
@@ -388,17 +432,26 @@ async fn lxc_vm_types_use_lxc_path() {
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
- // Containers use `rootfs`/`mpN` keys rather than `scsiN`/`virtioN`; they
- // are not returned by get_disks, and network keys without an explicit
- // model are still parsed.
+ // Containers use `rootfs`/`mpN` keys; they map onto Disk entries with the
+ // container-specific device names, and LXC network keys without an
+ // explicit model are still parsed.
let disks = manager
.get_disks("conn", "pve1", 201, "lxc")
.await
.expect("disks should be fetched");
- assert!(
- disks.is_empty(),
- "container disks are not in the scsi/virtio/... key set"
- );
+ assert_eq!(disks.len(), 2);
+ let rootfs = disks
+ .iter()
+ .find(|d| d.device == "rootfs")
+ .expect("rootfs disk should be parsed");
+ assert_eq!(rootfs.storage, "local");
+ assert_eq!(rootfs.size, 8589934592); // 8G
+ let mp0 = disks
+ .iter()
+ .find(|d| d.device == "mp0")
+ .expect("mp0 disk should be parsed");
+ assert_eq!(mp0.storage, "local");
+ assert_eq!(mp0.size, 4294967296); // 4G
let nics = manager
.get_network_interfaces("conn", "pve1", 201, "lxc")
@@ -407,6 +460,9 @@ async fn lxc_vm_types_use_lxc_path() {
assert_eq!(nics.len(), 1);
assert_eq!(nics[0].name, "net0");
assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0"));
+ // LXC format: `name=eth0` lead means no QEMU model=mac first segment.
+ assert_eq!(nics[0].model, "");
+ assert_eq!(nics[0].macaddr, "");
manager
.resize_disk("conn", "pve1", 201, "lxc", "rootfs", 17179869184)
@@ -417,6 +473,140 @@ async fn lxc_vm_types_use_lxc_path() {
resize_mock.assert();
}
+#[tokio::test]
+async fn add_nic_lxc_writes_lxc_net_format() {
+ let server = MockServer::start();
+ let token = "root@pam!add-nic-lxc-token";
+ let get_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/lxc/201/config");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": {"memory": 2048}
+ })
+ .to_string(),
+ );
+ });
+ let post_mock = server.mock(|when, then| {
+ when.method(POST)
+ .path("/api2/json/nodes/pve1/lxc/201/config")
+ // urlencoded: `name=eth0,type=veth,bridge=vmbr0,hwaddr=BC:24:11:8D:DF:95,firewall=1`
+ .body_includes(
+ "net0=name%3Deth0%2Ctype%3Dveth%2Cbridge%3Dvmbr0%2Chwaddr%3DBC%3A24%3A11%3A8D%3ADF%3A95%2Cfirewall%3D1",
+ );
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(r#"{"data":null}"#);
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
+ let config = AddNICConfig {
+ bridge: "vmbr0".to_string(),
+ // The model is ignored for LXC (containers always use veth), so an
+ // otherwise-invalid model still succeeds.
+ model: "veth".to_string(),
+ macaddr: Some("BC:24:11:8D:DF:95".to_string()),
+ tag: None,
+ firewall: Some(true),
+ };
+ manager
+ .add_nic("conn", "pve1", 201, "lxc", config)
+ .await
+ .expect("nic should be added");
+
+ get_mock.assert();
+ post_mock.assert();
+}
+
+#[tokio::test]
+async fn add_nic_lxc_random_mac_and_no_firewall_omits_hwaddr_and_firewall() {
+ let server = MockServer::start();
+ let token = "root@pam!add-nic-lxc-plain-token";
+ let get_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/lxc/201/config");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(r#"{"data":{"memory":2048}}"#);
+ });
+ let post_mock = server.mock(|when, then| {
+ when.method(POST)
+ .path("/api2/json/nodes/pve1/lxc/201/config")
+ .body_includes("net0=name%3Deth0%2Ctype%3Dveth%2Cbridge%3Dvmbr0")
+ .body_excludes("hwaddr")
+ .body_excludes("firewall");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(r#"{"data":null}"#);
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
+ let config = AddNICConfig {
+ bridge: "vmbr0".to_string(),
+ model: "veth".to_string(),
+ // `random` (or a missing MAC) means "let Proxmox assign one".
+ macaddr: Some("random".to_string()),
+ tag: None,
+ firewall: None,
+ };
+ manager
+ .add_nic("conn", "pve1", 201, "lxc", config)
+ .await
+ .expect("nic should be added");
+
+ get_mock.assert();
+ post_mock.assert();
+}
+
+#[tokio::test]
+async fn edit_nic_lxc_reencodes_lxc_format() {
+ let server = MockServer::start();
+ let token = "root@pam!edit-nic-lxc-token";
+ let get_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/lxc/201/config");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": {"net0": "name=eth0,type=veth,hwaddr=BC:24:11:8D:DF:95,bridge=vmbr0,ip=dhcp,firewall=0"}
+ })
+ .to_string(),
+ );
+ });
+ let post_mock = server.mock(|when, then| {
+ when.method(POST)
+ .path("/api2/json/nodes/pve1/lxc/201/config")
+ // urlencoded; the LXC form is rebuilt (`name=eth0,type=veth,
+ // hwaddr=...`) with the new bridge/firewall and the unknown
+ // `ip=dhcp` attribute carried over, but no tag.
+ .body_includes(
+ "net0=name%3Deth0%2Ctype%3Dveth%2Chwaddr%3DBC%3A24%3A11%3A8D%3ADF%3A95%2Cbridge%3Dvmbr1%2Cfirewall%3D1%2Cip%3Ddhcp",
+ )
+ .body_excludes("tag");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(r#"{"data":null}"#);
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
+ let config = EditNICConfig {
+ bridge: Some("vmbr1".to_string()),
+ model: None,
+ tag: None,
+ firewall: Some(true),
+ };
+ manager
+ .edit_nic("conn", "pve1", 201, "lxc", "net0", config)
+ .await
+ .expect("nic should be edited");
+
+ get_mock.assert();
+ post_mock.assert();
+}
+
#[tokio::test]
async fn invalid_vm_type_errors() {
let server = MockServer::start();
diff --git a/src-tauri/tests/api_snapshots_backups.rs b/src-tauri/tests/api_snapshots_backups.rs
index 169ee98..6864707 100644
--- a/src-tauri/tests/api_snapshots_backups.rs
+++ b/src-tauri/tests/api_snapshots_backups.rs
@@ -211,10 +211,12 @@ async fn get_backup_jobs_maps_list() {
.body(
serde_json::json!({
"data": [
- {"id": "backup-1", "store": "backup", "schedule": "0 2 * * *",
+ // The server sends `storage` (not `store`).
+ {"id": "backup-1", "storage": "backup", "schedule": "0 2 * * *",
"all": 1, "enabled": 1, "node": "pve1", "compress": "zstd",
"mode": "snapshot", "quiet": 0},
- {"id": "backup-2", "store": "local", "schedule": "30 3 * * 1",
+ // A vmid-selected job omits `all` entirely.
+ {"id": "backup-2", "storage": "local", "schedule": "30 3 * * 1",
"all": 0, "enabled": 0, "vmid": "100,101"}
]
})
@@ -238,7 +240,9 @@ async fn get_backup_jobs_maps_list() {
assert_eq!(jobs[0].compress.as_deref(), Some("zstd"));
assert_eq!(jobs[0].mode.as_deref(), Some("snapshot"));
assert_eq!(jobs[0].quiet, Some(0));
- // Tolerance: the second job omits the optional node/mode fields.
+ // Tolerance: the second job omits the optional node/mode fields and has no
+ // `all` key, which defaults to 0.
+ assert_eq!(jobs[1].store, "local");
assert_eq!(jobs[1].node, None);
assert_eq!(jobs[1].compress, None);
assert_eq!(jobs[1].mode, None);
@@ -248,6 +252,72 @@ async fn get_backup_jobs_maps_list() {
mock.assert();
}
+#[tokio::test]
+async fn backup_job_parses_realistic_pve_91_shape() {
+ let server = MockServer::start();
+ let token = "root@pam!jobs-real-token";
+ let mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/cluster/backup")
+ .header("Authorization", format!("PVEAPIToken={}", token));
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": [
+ // A job as PVE 9.1 actually emits it: `storage` key,
+ // no `all`, and a pile of fields the struct does not
+ // model (pool, notes-template, prune-backups, fleecing,
+ // next-run, notification-mode, ...).
+ {
+ "id": "backup-pve-1",
+ "storage": "kashyyk",
+ "schedule": "0 2 * * *",
+ "enabled": 1,
+ "node": "pve1",
+ "mode": "snapshot",
+ "compress": "zstd",
+ "vmid": "100,101,102",
+ "pool": "prod",
+ "notes-template": "{{guestname}}",
+ "prune-backups": {"keep-last": 3, "keep-daily": 7},
+ "fleecing": {"enabled": 1, "storage": "local-lvm"},
+ "next-run": 1760000000,
+ "notification-mode": "auto",
+ "bwlimit": 0,
+ "quiet": 0,
+ "starttime": "2026-08-01 02:00:00",
+ "stdexcludes": 0,
+ "remove": 0
+ }
+ ]
+ })
+ .to_string(),
+ );
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
+ let jobs = manager
+ .get_backup_jobs("conn")
+ .await
+ .expect("backup jobs should be fetched");
+
+ assert_eq!(jobs.len(), 1);
+ assert_eq!(jobs[0].id, "backup-pve-1");
+ // `storage` on the wire maps onto `store`.
+ assert_eq!(jobs[0].store, "kashyyk");
+ assert_eq!(jobs[0].schedule, "0 2 * * *");
+ // `all` is never sent for a vmid-selected job and defaults to 0.
+ assert_eq!(jobs[0].all, 0);
+ assert_eq!(jobs[0].enabled, 1);
+ assert_eq!(jobs[0].node.as_deref(), Some("pve1"));
+ assert_eq!(jobs[0].mode.as_deref(), Some("snapshot"));
+ assert_eq!(jobs[0].compress.as_deref(), Some("zstd"));
+ assert_eq!(jobs[0].vmid.as_deref(), Some("100,101,102"));
+ assert_eq!(jobs[0].quiet, Some(0));
+ mock.assert();
+}
+
#[tokio::test]
async fn create_backup_job_posts_form() {
let server = MockServer::start();
@@ -361,10 +431,27 @@ async fn get_backups_filters_and_maps() {
}
#[tokio::test]
-async fn get_backups_defaults_to_local_storage() {
+async fn get_backups_aggregates_over_single_backup_storage() {
let server = MockServer::start();
let token = "root@pam!backups-default-token";
- let mock = server.mock(|when, then| {
+ let resources_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/cluster/resources")
+ .query_param("type", "storage");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": [
+ {"storage": "local", "node": "pve1", "type": "dir",
+ "content": "backup,iso", "shared": 0,
+ "status": "available"}
+ ]
+ })
+ .to_string(),
+ );
+ });
+ let content_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/content")
.query_param("content", "backup");
@@ -380,7 +467,228 @@ async fn get_backups_defaults_to_local_storage() {
.expect("backups should be fetched");
assert!(backups.is_empty());
- mock.assert();
+ resources_mock.assert();
+ content_mock.assert();
+}
+
+#[tokio::test]
+async fn get_backups_aggregates_all_backup_storages_when_none_specified() {
+ let server = MockServer::start();
+ let token = "root@pam!backups-aggregate-token";
+ let resources_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/cluster/resources")
+ .query_param("type", "storage");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": [
+ {"storage": "backup1", "node": "pve1", "type": "nfs",
+ "content": "backup", "shared": 1,
+ "status": "available"},
+ {"storage": "backup2", "node": "pve2", "type": "nfs",
+ "content": "backup,iso", "shared": 1,
+ "status": "available"},
+ {"storage": "local", "node": "pve1", "type": "dir",
+ "content": "iso,vztmpl", "shared": 0,
+ "status": "available"}
+ ]
+ })
+ .to_string(),
+ );
+ });
+ let backup1_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/storage/backup1/content")
+ .query_param("content", "backup");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": [
+ {"volid": "backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
+ "backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
+ "backup-type": "qemu", "backup-id": "100",
+ "backup-time": 1700000000, "storage": "backup1",
+ "size": 1073741824u64, "ctime": 1700000001, "content": "backup"}
+ ]
+ })
+ .to_string(),
+ );
+ });
+ let backup2_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/storage/backup2/content")
+ .query_param("content", "backup");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": [
+ {"volid": "backup2:backup/vzdump-qemu-201-2024_01_02-00_00_00.vma.zst",
+ "backupid": "vzdump-qemu-201-2024_01_02-00_00_00.vma.zst",
+ "backup-type": "qemu", "backup-id": "201",
+ "backup-time": 1700000100, "storage": "backup2",
+ "size": 2147483648u64, "ctime": 1700000101, "content": "backup"}
+ ]
+ })
+ .to_string(),
+ );
+ });
+ // A storage without `backup` content must never be queried.
+ let local_probe = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/storage/local/content")
+ .query_param("content", "backup");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(r#"{"data":[]}"#);
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
+ let backups = manager
+ .get_backups("conn", None)
+ .await
+ .expect("backups should be aggregated");
+
+ assert_eq!(backups.len(), 2);
+ assert_eq!(
+ backups[0].volid,
+ "backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
+ );
+ assert_eq!(
+ backups[1].volid,
+ "backup2:backup/vzdump-qemu-201-2024_01_02-00_00_00.vma.zst"
+ );
+ resources_mock.assert();
+ backup1_mock.assert();
+ backup2_mock.assert();
+ assert_eq!(
+ local_probe.calls(),
+ 0,
+ "a storage without backup content must not be queried"
+ );
+}
+
+#[tokio::test]
+async fn get_backups_specific_storage_only() {
+ let server = MockServer::start();
+ let token = "root@pam!backups-specific-token";
+ let content_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/storage/backup1/content")
+ .query_param("content", "backup");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": [
+ {"volid": "backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
+ "backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
+ "backup-type": "qemu", "backup-id": "100",
+ "backup-time": 1700000000, "storage": "backup1",
+ "size": 1073741824u64, "ctime": 1700000001, "content": "backup"}
+ ]
+ })
+ .to_string(),
+ );
+ });
+ // The storage list must not be consulted when a specific storage is given.
+ let resources_probe = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/cluster/resources")
+ .query_param("type", "storage");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(r#"{"data":[]}"#);
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
+ let backups = manager
+ .get_backups("conn", Some("backup1"))
+ .await
+ .expect("backups should be fetched");
+
+ assert_eq!(backups.len(), 1);
+ assert_eq!(
+ backups[0].volid,
+ "backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
+ );
+ content_mock.assert();
+ assert_eq!(
+ resources_probe.calls(),
+ 0,
+ "the storage list must not be queried for a specific storage"
+ );
+}
+
+#[tokio::test]
+async fn get_backups_aggregation_skips_erroring_storage() {
+ let server = MockServer::start();
+ let token = "root@pam!backups-skip-token";
+ let resources_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/cluster/resources")
+ .query_param("type", "storage");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": [
+ {"storage": "bad", "node": "pve1", "type": "nfs",
+ "content": "backup", "shared": 1,
+ "status": "available"},
+ {"storage": "good", "node": "pve1", "type": "nfs",
+ "content": "backup", "shared": 1,
+ "status": "available"}
+ ]
+ })
+ .to_string(),
+ );
+ });
+ let bad_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/storage/bad/content")
+ .query_param("content", "backup");
+ then.status(500)
+ .header("content-type", "application/json")
+ .body(r#"{"data":null}"#);
+ });
+ let good_mock = server.mock(|when, then| {
+ when.method(GET)
+ .path("/api2/json/nodes/pve1/storage/good/content")
+ .query_param("content", "backup");
+ then.status(200)
+ .header("content-type", "application/json")
+ .body(
+ serde_json::json!({
+ "data": [
+ {"volid": "good:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
+ "backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
+ "backup-type": "qemu", "backup-id": "100",
+ "backup-time": 1700000000, "storage": "good",
+ "size": 1073741824u64, "ctime": 1700000001, "content": "backup"}
+ ]
+ })
+ .to_string(),
+ );
+ });
+
+ let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
+ let backups = manager
+ .get_backups("conn", None)
+ .await
+ .expect("aggregation should succeed despite one erroring storage");
+
+ assert_eq!(backups.len(), 1);
+ assert_eq!(
+ backups[0].volid,
+ "good:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
+ );
+ resources_mock.assert();
+ bad_mock.assert();
+ good_mock.assert();
}
#[tokio::test]
diff --git a/src-tauri/tests/cluster_flow.rs b/src-tauri/tests/cluster_flow.rs
index 17a40e0..84d4dab 100644
--- a/src-tauri/tests/cluster_flow.rs
+++ b/src-tauri/tests/cluster_flow.rs
@@ -426,7 +426,7 @@ async fn status_info_when_disconnected() {
assert_eq!(info.current_endpoint_url, server.base_url());
assert!(info.nodes.is_empty());
assert_eq!(
- version_mock.hits(),
+ version_mock.calls(),
0,
"a disconnected connection must not hit the network"
);
diff --git a/src-tauri/tests/failover.rs b/src-tauri/tests/failover.rs
index cebb486..11b529b 100644
--- a/src-tauri/tests/failover.rs
+++ b/src-tauri/tests/failover.rs
@@ -169,7 +169,7 @@ async fn non_transport_error_does_not_rotate() {
);
primary_mock.assert();
assert_eq!(
- fallback_mock.hits(),
+ fallback_mock.calls(),
0,
"a non-transport error must not trigger failover"
);
@@ -192,7 +192,7 @@ async fn request_succeeds_on_primary_again_when_it_returns() {
assert!(vms.is_empty());
primary_mock.assert();
- assert_eq!(fallback_mock.hits(), 0);
+ assert_eq!(fallback_mock.calls(), 0);
assert_eq!(
manager
.runtime_status("conn")
diff --git a/src-tauri/tests/live.rs b/src-tauri/tests/live.rs
new file mode 100644
index 0000000..5989657
--- /dev/null
+++ b/src-tauri/tests/live.rs
@@ -0,0 +1,1092 @@
+//! LIVE READ-ONLY integration test harness.
+//!
+//! Drives the app's real HTTP stack against a real Proxmox cluster to verify
+//! every read endpoint and cluster connectivity. All tests are `#[ignore]`d
+//! and only run with `--ignored`; they read `LIVE_PVE_URL` / `LIVE_PVE_USER` /
+//! `LIVE_PVE_PASS` from the environment and skip (with an explanation) when
+//! `LIVE_PVE_PASS` is unset.
+//!
+//! HARD RULE: no state-changing HTTP request is ever issued against Proxmox.
+//! The only non-GET request is the authentication handshake
+//! `POST /api2/json/access/ticket`, which only issues a session ticket.
+//!
+//! The OS keyring is deliberately never touched: `set_session_ticket` injects
+//! the password-mode session into the in-memory connection state, so
+//! `login_with_password`/`connect` (which write to the keyring) are NOT used.
+//!
+//! Run with:
+//! TMPDIR=/home/user/.cache/clustri-build-tmp \
+//! LIVE_PVE_URL=https://10.0.0.22:8006 LIVE_PVE_USER=ro_opencode@pve \
+//! LIVE_PVE_PASS='...' \
+//! cargo test --test live -- --ignored --nocapture
+
+use std::collections::BTreeSet;
+use std::time::Duration;
+
+use clustri::{
+ api_request, derive_node_url, AuthContext, AuthMode, ConnectionConfig, ConnectionManager,
+ EndpointConfig,
+};
+use reqwest::Method;
+
+// ---------------------------------------------------------------------------
+// Environment + shared helpers
+// ---------------------------------------------------------------------------
+
+/// Reads the live environment. Returns `None` (with an explanation printed)
+/// when `LIVE_PVE_PASS` is unset or empty so the test can skip cleanly.
+fn live_env() -> Option<(String, String, String)> {
+ let pass = std::env::var("LIVE_PVE_PASS").ok().filter(|p| !p.is_empty())?;
+ let url = std::env::var("LIVE_PVE_URL")
+ .unwrap_or_else(|_| "https://10.0.0.22:8006".to_string());
+ let user = std::env::var("LIVE_PVE_USER")
+ .unwrap_or_else(|_| "ro_opencode@pve".to_string());
+ Some((url, user, pass))
+}
+
+fn skip_reason() -> String {
+ "LIVE_PVE_PASS env var is not set — skipping live test. Set LIVE_PVE_PASS (and optionally LIVE_PVE_URL / LIVE_PVE_USER) to run it.".to_string()
+}
+
+/// Prints a section label to stderr (visible with `--nocapture`).
+fn label(msg: &str) {
+ eprintln!("\n========== {} ==========", msg);
+}
+
+/// Builds the same permissive HTTP client the app uses at the transport layer
+/// (self-signed Proxmox certs are the norm; trust is an application-level
+/// concern that does not apply to a throwaway read-only harness).
+fn live_client() -> Result {
+ reqwest::Client::builder()
+ .danger_accept_invalid_certs(true)
+ .connect_timeout(Duration::from_secs(10))
+ .timeout(Duration::from_secs(45))
+ .build()
+ .map_err(|e| format!("failed to build HTTP client: {}", e))
+}
+
+/// Performs the password authentication handshake against the real server:
+/// `POST {url}/api2/json/access/ticket` with a form-encoded body. Returns
+/// `(ticket, csrf_token)`. This mirrors the app's own `login_with_password`
+/// transport (which is unusable here only because it also writes to the
+/// keyring). This is the ONLY state-adjacent request in the harness and it
+/// only issues a session ticket.
+async fn login(url: &str, user: &str, pass: &str) -> Result<(String, String), String> {
+ let client = live_client()?;
+ let body = url::form_urlencoded::Serializer::new(String::new())
+ .append_pair("username", user)
+ .append_pair("password", pass)
+ .finish();
+ let login_url = format!("{}/api2/json/access/ticket", url.trim_end_matches('/'));
+ let response = client
+ .post(&login_url)
+ .header(reqwest::header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(body)
+ .send()
+ .await
+ .map_err(|e| format!("login POST failed: {}", e))?;
+ let status = response.status();
+ let body: serde_json::Value = response
+ .json()
+ .await
+ .map_err(|e| format!("login response parse failed: {}", e))?;
+ if !status.is_success() {
+ return Err(format!("login failed (HTTP {}): {}", status, body));
+ }
+ let ticket = body["data"]["ticket"]
+ .as_str()
+ .ok_or_else(|| format!("no ticket in login response: {}", body))?
+ .to_string();
+ let csrf = body["data"]["CSRFPreventionToken"]
+ .as_str()
+ .unwrap_or("")
+ .to_string();
+ Ok((ticket, csrf))
+}
+
+/// Builds a `ConnectionManager` with one password-mode connection (`"conn"`,
+/// `accept_untrusted: true`) and injects the session ticket in memory via
+/// `set_session_ticket` (no keyring). Returns the manager, the temp dir
+/// (kept alive so the connections file stays valid), and the raw ticket.
+async fn manager_with_session(
+ url: &str,
+ user: &str,
+ pass: &str,
+) -> Result<(ConnectionManager, tempfile::TempDir, String), String> {
+ let (ticket, csrf) = login(url, user, pass).await?;
+ let dir = tempfile::tempdir().map_err(|e| format!("tempdir failed: {}", e))?;
+ 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.trim_end_matches('/').to_string(),
+ 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(user.to_string()),
+ nodes: vec![],
+ cluster_id: None,
+ };
+ manager
+ .add_connection(config, &path)
+ .await
+ .map_err(|e| format!("add_connection failed: {}", e))?;
+ manager
+ .set_session_ticket("conn", &ticket, &csrf)
+ .await
+ .map_err(|e| format!("set_session_ticket failed: {}", e))?;
+ Ok((manager, dir, ticket))
+}
+
+/// A password-mode `AuthContext` carrying the login ticket (cookie auth).
+fn ticket_auth(ticket: &str) -> AuthContext {
+ AuthContext {
+ mode: AuthMode::Password,
+ token: None,
+ ticket: Some(ticket.to_string()),
+ csrf_token: None,
+ }
+}
+
+/// Fetches the raw (unparsed) VM/container config object for printing the
+/// verbatim disk / net value strings.
+async fn raw_config(
+ url: &str,
+ ticket: &str,
+ node: &str,
+ vmid: u32,
+ vm_type: &str,
+) -> Result {
+ let client = live_client()?;
+ let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid);
+ api_request(&client, url, Method::GET, &path, &ticket_auth(ticket), &[], None)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Raw authenticated GET that keeps the HTTP status and body even on a
+/// non-success response, so parameter-verification failures can be inspected
+/// verbatim. Cookie auth mirrors `api_request` password mode.
+async fn raw_fetch(
+ url: &str,
+ ticket: &str,
+ path: &str,
+ query: &[(&str, &str)],
+) -> Result<(reqwest::StatusCode, serde_json::Value), String> {
+ let client = live_client()?;
+ let mut target = format!("{}/api2/json{}", url.trim_end_matches('/'), path);
+ if !query.is_empty() {
+ let q = query
+ .iter()
+ .map(|(k, v)| format!("{}={}", k, v))
+ .collect::>()
+ .join("&");
+ target.push('?');
+ target.push_str(&q);
+ }
+ let response = client
+ .get(&target)
+ .header(reqwest::header::COOKIE, format!("PVEAuthCookie={}", ticket))
+ .send()
+ .await
+ .map_err(|e| format!("GET {} failed: {}", target, e))?;
+ let status = response.status();
+ let body: serde_json::Value = response
+ .json()
+ .await
+ .unwrap_or_else(|_| serde_json::Value::Null);
+ Ok((status, body))
+}
+
+/// Prints a compact view of a raw JSON value: the `data` payload when the
+/// standard `{ "data": ... }` envelope is present (an array becomes a count +
+/// sample; an object prints in full), otherwise the whole envelope so error
+/// bodies (`errors` / `message`) stay visible.
+fn print_raw(label: &str, status: reqwest::StatusCode, body: &serde_json::Value) {
+ let payload = match body.get("data") {
+ Some(serde_json::Value::Null) | None => body.clone(),
+ Some(inner) => inner.clone(),
+ };
+ let text = serde_json::to_string(&payload).unwrap_or_default();
+ match &payload {
+ serde_json::Value::Array(items) => {
+ eprintln!(" {} -> HTTP {} array ({} entries)", label, status, items.len());
+ for item in items.iter().take(3) {
+ eprintln!(" {}", serde_json::to_string(item).unwrap_or_default());
+ }
+ if items.len() > 3 {
+ eprintln!(" ... ({} more)", items.len() - 3);
+ }
+ }
+ serde_json::Value::Object(_) => {
+ eprintln!(" {} -> HTTP {}: {}", label, status, text);
+ }
+ other => eprintln!(" {} -> HTTP {}: {}", label, status, other),
+ }
+}
+
+/// The entries of a raw response's `data` field when it is an array.
+fn data_array(body: &serde_json::Value) -> Vec {
+ body.get("data")
+ .and_then(|d| d.as_array())
+ .cloned()
+ .unwrap_or_default()
+}
+
+fn is_disk_key(key: &str) -> bool {
+ let bus_key = |bus: &str| {
+ key.strip_prefix(bus)
+ .map_or(false, |suffix| !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()))
+ };
+ ["scsi", "virtio", "ide", "sata", "nvme"].iter().any(|bus| bus_key(bus))
+ || key == "rootfs"
+ || bus_key("mp") // LXC mount points (mp0, mp1, ...)
+}
+
+fn is_net_key(key: &str) -> bool {
+ key.strip_prefix("net")
+ .map_or(false, |suffix| !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()))
+}
+
+// ---------------------------------------------------------------------------
+// Test 1: every read endpoint parses against real data
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+#[ignore]
+async fn live_all_read_endpoints_parse() {
+ let Some((url, user, pass)) = live_env() else {
+ eprintln!("{}", skip_reason());
+ return;
+ };
+ eprintln!("Live target: {} (user {})", url, user);
+ let (manager, _dir, ticket) = match manager_with_session(&url, &user, &pass).await {
+ Ok(v) => v,
+ Err(e) => panic!("setup failed: {}", e),
+ };
+
+ let mut failures: Vec = Vec::new();
+ let mut first_online_node: Option = None;
+
+ // ---- get_nodes --------------------------------------------------------
+ label("get_nodes");
+ match manager.get_nodes("conn").await {
+ Ok(nodes) => {
+ for n in &nodes {
+ eprintln!(
+ " node={:<10} status={:<8} cpu={:<6} maxcpu={:<4} uptime={}",
+ n.node, n.status, n.cpu, n.maxcpu, n.uptime
+ );
+ }
+ first_online_node = nodes.iter().find(|n| n.status == "online").map(|n| n.node.clone());
+ eprintln!(" first_online_node = {:?}", first_online_node);
+ }
+ Err(e) => failures.push(format!("get_nodes: {}", e)),
+ }
+
+ // ---- get_cluster_status ------------------------------------------------
+ label("get_cluster_status");
+ let mut cluster_nodes_len = 0usize;
+ let mut cluster_status_ok = false;
+ match manager.get_cluster_status("conn").await {
+ Ok(status) => {
+ cluster_status_ok = true;
+ let nodes = status.nodes.as_ref();
+ cluster_nodes_len = nodes.map(|n| n.len()).unwrap_or(0);
+ eprintln!(" cluster name={:?} id={:?} node_count={}", status.name, status.id, cluster_nodes_len);
+ for cn in nodes.into_iter().flatten() {
+ eprintln!(
+ " node name={:<10} nodeid={:<4} online={} local={:?} ip={:?}",
+ cn.name, cn.nodeid, cn.online, cn.local, cn.ip
+ );
+ }
+ }
+ Err(e) => {
+ failures.push(format!("get_cluster_status: {}", e));
+ eprintln!(" ERROR: {} (the endpoint was denied / errored)", e);
+ }
+ }
+ if cluster_status_ok {
+ assert!(
+ cluster_nodes_len >= 2,
+ "this is a real cluster: expected >= 2 nodes in /cluster/status, got {}",
+ cluster_nodes_len
+ );
+ } else {
+ eprintln!(
+ " [note] /cluster/status was not readable for this user, so the '>= 2 cluster nodes' assertion was NOT evaluated"
+ );
+ }
+
+ // ---- get_vms -----------------------------------------------------------
+ label("get_vms");
+ let mut vms = Vec::new();
+ match manager.get_vms("conn").await {
+ Ok(list) => {
+ eprintln!(" total VMs: {}", list.len());
+ eprintln!(" {:<6} {:<5} {:<9} {:<8} {}", "vmid", "type", "status", "node", "name");
+ for v in &list {
+ eprintln!(
+ " {:<6} {:<5} {:<9} {:<8} {:?}",
+ v.vmid, v.r#type, v.status, v.node, v.name
+ );
+ }
+ vms = list;
+ }
+ Err(e) => failures.push(format!("get_vms: {}", e)),
+ }
+
+ // ---- get_storage ---------------------------------------------------------
+ label("get_storage");
+ let mut storages = Vec::new();
+ match manager.get_storage("conn").await {
+ Ok(list) => {
+ eprintln!(" total storages: {}", list.len());
+ for s in &list {
+ eprintln!(
+ " storage={:<20} type={:<6} content={:<30} enabled={} active={} shared={} node={}",
+ s.storage, s.r#type, s.content, s.enabled, s.active, s.shared, s.node
+ );
+ }
+ storages = list;
+ }
+ Err(e) => failures.push(format!("get_storage: {}", e)),
+ }
+ if storages.is_empty() {
+ eprintln!(" [note] no storages are visible to this user — per-storage content/detail checks below will be skipped");
+ }
+
+ // ---- per-storage content + detail -----------------------------------------
+ label("per-storage content & detail");
+ for s in &storages {
+ // Probe each storage through its OWN node (the `node` field of the
+ // storage list entry). Node-local storages are only servable by their
+ // owning node — the first-online-node path fails with "storage not
+ // available on node". Shared storages respond from any node.
+ let node = if s.node.is_empty() {
+ first_online_node.as_deref().unwrap_or("")
+ } else {
+ s.node.as_str()
+ };
+ if node.is_empty() {
+ failures.push(format!(
+ "get_storage_content({}): no node available for storage",
+ s.storage
+ ));
+ failures.push(format!(
+ "get_storage_detail({}): no node available for storage detail",
+ s.storage
+ ));
+ continue;
+ }
+ // Content via the storage's own node.
+ match manager
+ .get_storage_content("conn", &s.storage, Some(node))
+ .await
+ {
+ Ok(items) => {
+ eprintln!(" storage={:<20} content(Some({})) -> {} entries", s.storage, node, items.len());
+ if items.is_empty() {
+ eprintln!(" (empty)");
+ }
+ }
+ Err(e) => {
+ failures.push(format!("get_storage_content({}, Some({})): {}", s.storage, node, e))
+ }
+ }
+ // Detail via the storage's own node.
+ match manager.get_storage_detail("conn", node, &s.storage).await {
+ Ok(detail) => {
+ eprintln!(
+ " storage={:<20} detail({}) -> storage={} type={} content={} enabled={} active={} shared={} used={} total={} avail={}",
+ s.storage,
+ node,
+ detail.storage,
+ detail.r#type,
+ detail.content,
+ detail.enabled,
+ detail.active,
+ detail.shared,
+ detail.used,
+ detail.total,
+ detail.avail
+ );
+ }
+ Err(e) => {
+ failures.push(format!("get_storage_detail({}, {}): {}", s.storage, node, e));
+ // Raw capture: the exact field shape the struct mis-parses.
+ let path = format!("/nodes/{}/storage/{}/status", node, s.storage);
+ match raw_fetch(&url, &ticket, &path, &[]).await {
+ Ok((status, raw)) => print_raw(
+ &format!("RAW /storage/{}/status on {}", s.storage, node),
+ status,
+ &raw,
+ ),
+ Err(e2) => eprintln!(" RAW /storage/{}/status fetch failed: {}", s.storage, e2),
+ }
+ }
+ }
+ }
+
+ // ---- raw /cluster/resources (type=storage) field shapes --------------------
+ label("raw /cluster/resources (storage entries)");
+ match raw_fetch(&url, &ticket, "/cluster/resources", &[("type", "storage")]).await {
+ Ok((status, body)) => {
+ let entries = data_array(&body);
+ eprintln!(" HTTP {} -> {} storage entries", status, entries.len());
+ if let Some(first) = entries.first() {
+ if let Some(obj) = first.as_object() {
+ let mut keys: Vec<_> = obj.keys().cloned().collect();
+ keys.sort();
+ eprintln!(" field keys: {:?}", keys);
+ }
+ }
+ for entry in entries.iter().take(3) {
+ eprintln!(" {}", serde_json::to_string(entry).unwrap_or_default());
+ }
+ }
+ Err(e) => eprintln!(" ERROR: raw /cluster/resources fetch failed: {}", e),
+ }
+
+ // ---- get_tasks -------------------------------------------------------------
+ label("get_tasks");
+ match manager.get_tasks("conn").await {
+ Ok(tasks) => {
+ eprintln!(" task count: {}", tasks.len());
+ for t in tasks.iter().take(5) {
+ eprintln!(
+ " upid={} node={} type={} id={} user={} status={:?} exitstatus={:?}",
+ t.upid, t.node, t.r#type, t.id, t.user, t.status, t.exitstatus
+ );
+ }
+ if tasks.len() > 5 {
+ eprintln!(" ... ({} more)", tasks.len() - 5);
+ }
+ }
+ Err(e) => {
+ eprintln!(" ERROR: get_tasks failed: {}", e);
+ failures.push(format!("get_tasks: {}", e));
+ }
+ }
+
+ // ---- raw task probes ----------------------------------------------------------
+ // The app now sends plain `/cluster/tasks` (the server rejects any
+ // `limit=` query with HTTP 400); probe variants to confirm the rejection
+ // and the node-scoped `/nodes/{node}/tasks` alternative.
+ label("raw task probes");
+ let probe_node = first_online_node.as_deref().unwrap_or("conn");
+ let probes: Vec<(String, Vec<(&str, &str)>)> = vec![
+ ("/cluster/tasks".to_string(), vec![]),
+ ("/cluster/tasks".to_string(), vec![("limit", "50")]),
+ ("/cluster/tasks".to_string(), vec![("limit", "10")]),
+ (format!("/nodes/{}/tasks", probe_node), vec![("limit", "50")]),
+ ];
+ for (path, query) in &probes {
+ match raw_fetch(&url, &ticket, path, query).await {
+ Ok((status, body)) => print_raw(&format!("GET {}", path), status, &body),
+ Err(e) => eprintln!(" GET {} failed: {}", path, e),
+ }
+ }
+
+ // ---- get_backup_jobs (parsed + full raw JSON) --------------------------------
+ label("get_backup_jobs");
+ match manager.get_backup_jobs("conn").await {
+ Ok(jobs) => {
+ eprintln!(" parsed job count: {}", jobs.len());
+ for j in &jobs {
+ eprintln!(
+ " id={} store={} schedule={:?} all={} enabled={} node={:?} vmid={:?} compress={:?} mode={:?} quiet={:?}",
+ j.id, j.store, j.schedule, j.all, j.enabled, j.node, j.vmid, j.compress, j.mode, j.quiet
+ );
+ }
+ }
+ Err(e) => {
+ eprintln!(" ERROR: get_backup_jobs failed: {}", e);
+ failures.push(format!("get_backup_jobs: {}", e));
+ }
+ }
+ // Raw JSON of every job entry (captures the full field shape the parsed
+ // struct drops).
+ let client = match live_client() {
+ Ok(c) => Some(c),
+ Err(e) => {
+ failures.push(format!("raw backup jobs client: {}", e));
+ None
+ }
+ };
+ if let Some(client) = client {
+ let auth = ticket_auth(&ticket);
+ match api_request(&client, &url, Method::GET, "/cluster/backup", &auth, &[], None).await {
+ Ok(raw) => {
+ let entries = raw.as_array().cloned().unwrap_or_default();
+ eprintln!(" RAW entries ({}):", entries.len());
+ for entry in &entries {
+ eprintln!(" {}", serde_json::to_string(entry).unwrap_or_default());
+ }
+ }
+ Err(e) => {
+ eprintln!(" ERROR: raw /cluster/backup fetch failed: {}", e);
+ failures.push(format!("raw /cluster/backup: {}", e));
+ }
+ }
+ }
+
+ // ---- get_backups(conn, None) aggregation --------------------------------------
+ label("get_backups(conn, None)");
+ match manager.get_backups("conn", None).await {
+ Ok(backups) => {
+ eprintln!(" aggregated backup count: {}", backups.len());
+ let mut storages = BTreeSet::new();
+ for b in &backups {
+ storages.insert(b.storage.clone());
+ if let Some(prefix) = b.volid.split(':').next() {
+ storages.insert(prefix.to_string());
+ }
+ }
+ eprintln!(" storages aggregated (from storage field / volid prefix): {:?}", storages);
+ for b in backups.iter().take(8) {
+ eprintln!(
+ " volid={} backupid={} type={} id={} time={} size={} storage={}",
+ b.volid, b.backupid, b.backup_type, b.backup_id, b.backup_time, b.size, b.storage
+ );
+ }
+ if backups.len() > 8 {
+ eprintln!(" ... ({} more)", backups.len() - 8);
+ }
+ }
+ Err(e) => failures.push(format!("get_backups(conn, None): {}", e)),
+ }
+
+ // ---- raw backup-storage content ------------------------------------------------
+ // The backup jobs target kashyyk / bb-pve-b2 / ots-pve-b2; dump the raw
+ // node-scoped content so the actual backup volumeids are visible (the
+ // parsed content(None) path returned empty).
+ label("raw backup storage content");
+ let backup_storages = ["kashyyk", "bb-pve-b2", "ots-pve-b2"];
+ let content_node = first_online_node.as_deref().unwrap_or("conn");
+ for storage in backup_storages {
+ let path = format!("/nodes/{}/storage/{}/content", content_node, storage);
+ match raw_fetch(&url, &ticket, &path, &[("content", "backup")]).await {
+ Ok((status, body)) => print_raw(
+ &format!("GET {}/content?content=backup", path),
+ status,
+ &body,
+ ),
+ Err(e) => eprintln!(" GET {} failed: {}", path, e),
+ }
+ }
+
+ // ---- per-guest disk / network / snapshot raw data --------------------------------
+ label("guest disks / nics / snapshots");
+ let first_qemu = vms.iter().find(|v| v.r#type == "qemu");
+ let first_lxc = vms.iter().find(|v| v.r#type == "lxc");
+ let guests: Vec<(&str, u32, &str)> = vec![
+ ("qemu", first_qemu.map(|v| v.vmid).unwrap_or(0), first_qemu.map(|v| v.node.as_str()).unwrap_or("")),
+ ("lxc", first_lxc.map(|v| v.vmid).unwrap_or(0), first_lxc.map(|v| v.node.as_str()).unwrap_or("")),
+ ];
+ for (vm_type, vmid, node) in guests {
+ if vmid == 0 || node.is_empty() {
+ eprintln!(" no {} guest found — skipping", vm_type);
+ continue;
+ }
+ eprintln!(" --- {} guest vmid={} on node={} ---", vm_type, vmid, node);
+
+ match manager.get_disks("conn", node, vmid, vm_type).await {
+ Ok(disks) => {
+ eprintln!(" parsed disks ({}):", disks.len());
+ for d in &disks {
+ eprintln!(
+ " {}: storage={} size={} format={}",
+ d.device, d.storage, d.size, d.format
+ );
+ }
+ }
+ Err(e) => failures.push(format!("get_disks({}, {}, {}): {}", node, vmid, vm_type, e)),
+ }
+ // Raw disk value strings from the VM config.
+ match raw_config(&url, &ticket, node, vmid, vm_type).await {
+ Ok(cfg) => {
+ eprintln!(" RAW disk config value strings:");
+ if let Some(obj) = cfg.as_object() {
+ let mut keys: Vec<_> = obj.keys().cloned().collect();
+ keys.sort();
+ for k in keys {
+ if is_disk_key(&k) {
+ if let Some(v) = obj.get(&k).and_then(|v| v.as_str()) {
+ eprintln!(" {} = {}", k, v);
+ }
+ }
+ }
+ }
+ }
+ Err(e) => failures.push(format!("raw_config({}, {}, {}): {}", node, vmid, vm_type, e)),
+ }
+
+ match manager.get_network_interfaces("conn", node, vmid, vm_type).await {
+ Ok(nics) => {
+ eprintln!(" parsed nics ({}):", nics.len());
+ for nic in &nics {
+ eprintln!(
+ " {}: model={} mac={} bridge={:?} tag={:?} firewall={:?} link_down={:?}",
+ nic.name, nic.model, nic.macaddr, nic.bridge, nic.tag, nic.firewall, nic.link_down
+ );
+ }
+ }
+ Err(e) => {
+ failures.push(format!("get_network_interfaces({}, {}, {}): {}", node, vmid, vm_type, e))
+ }
+ }
+ match raw_config(&url, &ticket, node, vmid, vm_type).await {
+ Ok(cfg) => {
+ eprintln!(" RAW net config value strings:");
+ if let Some(obj) = cfg.as_object() {
+ let mut keys: Vec<_> = obj.keys().cloned().collect();
+ keys.sort();
+ for k in keys {
+ if is_net_key(&k) {
+ if let Some(v) = obj.get(&k).and_then(|v| v.as_str()) {
+ eprintln!(" {} = {}", k, v);
+ }
+ }
+ }
+ }
+ }
+ Err(e) => failures.push(format!("raw_config net({}, {}, {}): {}", node, vmid, vm_type, e)),
+ }
+
+ match manager.get_snapshots("conn", node, vmid, vm_type).await {
+ Ok(snaps) => {
+ eprintln!(" snapshots ({}):", snaps.len());
+ for s in &snaps {
+ eprintln!(
+ " name={} vmstate={} snaptime={} parent={:?}",
+ s.name, s.vmstate, s.snaptime, s.parent
+ );
+ }
+ }
+ Err(e) => failures.push(format!("get_snapshots({}, {}, {}): {}", node, vmid, vm_type, e)),
+ }
+ }
+
+ // ---- get_websocket_url ---------------------------------------------------------
+ label("get_websocket_url");
+ let ws_node = first_online_node.as_deref().unwrap_or("conn");
+ match manager.get_websocket_url("conn", ws_node).await {
+ Ok(ws_url) => eprintln!(" websocket base origin: {}", ws_url),
+ Err(e) => failures.push(format!("get_websocket_url: {}", e)),
+ }
+
+ // ---- summary --------------------------------------------------------------------
+ label("SUMMARY");
+ if failures.is_empty() {
+ eprintln!(" ALL READ ENDPOINTS PASSED");
+ } else {
+ eprintln!(" {} endpoint failures:", failures.len());
+ for f in &failures {
+ eprintln!(" - {}", f);
+ }
+ panic!("{} endpoint failure(s):\n - {}", failures.len(), failures.join("\n - "));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Test 2: node discovery + per-node reachability
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+#[ignore]
+async fn live_discover_nodes_and_reachability() {
+ let Some((url, user, pass)) = live_env() else {
+ eprintln!("{}", skip_reason());
+ return;
+ };
+ eprintln!("Live target: {} (user {})", url, user);
+ let (mut manager, _dir, ticket) = match manager_with_session(&url, &user, &pass).await {
+ Ok(v) => v,
+ Err(e) => panic!("setup failed: {}", e),
+ };
+
+ label("discover_nodes");
+ let primary_url = url.trim_end_matches('/').to_string();
+ let discovered = match manager.discover_nodes("conn").await {
+ Ok(d) => Some(d),
+ Err(e) => {
+ eprintln!(" discover_nodes FAILED: {}", e);
+ eprintln!(
+ " NOTE: this is a credentials/permission limitation — /cluster/status needs Sys.Audit on /,\n which this user lacks, so cluster node IPs are unavailable for URL derivation.\n Falling back to name-derived URLs via derive_node_url."
+ );
+ None
+ }
+ };
+
+ // Candidate node URLs: real discovered URLs when discovery worked,
+ // otherwise name-derived URLs (best effort).
+ let mut candidates: Vec<(String, bool)> = Vec::new(); // (url, is_primary)
+ match &discovered {
+ Some(nodes) => {
+ for n in nodes {
+ eprintln!(
+ " node={:<10} status={:<8} is_primary={:<5} local={} url={}",
+ n.name, n.status, n.is_primary, n.local, n.url
+ );
+ candidates.push((n.url.clone(), n.is_primary));
+ }
+ }
+ None => {
+ let nodes = manager
+ .get_nodes("conn")
+ .await
+ .expect("get_nodes must still work even without /cluster/status");
+ for n in &nodes {
+ let cand_url = derive_node_url(&primary_url, None, &n.node);
+ let is_primary = cand_url.eq_ignore_ascii_case(&primary_url);
+ eprintln!(
+ " node={:<10} url={}{}",
+ n.node,
+ cand_url,
+ if is_primary { " [PRIMARY]" } else { " (name-derived; cluster IP unknown)" }
+ );
+ candidates.push((cand_url, is_primary));
+ }
+ if !candidates.iter().any(|(_, is_primary)| *is_primary) {
+ eprintln!(
+ " (no candidate matched the primary URL — treating the connection's primary URL itself as the primary node)"
+ );
+ candidates.push((primary_url.clone(), true));
+ }
+ }
+ }
+
+ label("per-node reachability (GET /api2/json/version)");
+ let client = match live_client() {
+ Ok(c) => c,
+ Err(e) => panic!("client build failed: {}", e),
+ };
+ let auth = ticket_auth(&ticket);
+ let mut reachable = 0usize;
+ let mut unreachable: Vec = Vec::new();
+
+ // Direct reachability against each candidate node URL.
+ for (cand_url, is_primary) in &candidates {
+ match api_request(&client, cand_url, Method::GET, "/version", &auth, &[], None).await {
+ Ok(value) => {
+ reachable += 1;
+ eprintln!(
+ " {} REACHABLE -> version={} release={} (primary={})",
+ cand_url,
+ value["version"].as_str().unwrap_or("?"),
+ value["release"].as_str().unwrap_or("?"),
+ is_primary
+ );
+ }
+ Err(e) => {
+ unreachable.push(format!("{}: {}", cand_url, e));
+ eprintln!(" {} UNREACHABLE -> {} (primary={})", cand_url, e, is_primary);
+ }
+ }
+ }
+
+ // Cluster-proxy reachability: every node queried through the primary
+ // endpoint. This works even when the node IPs are unknown (pveproxy
+ // forwards node-scoped requests to the target node) and proves each node
+ // is up and servable by the cluster.
+ let node_names: Vec = match &discovered {
+ Some(nodes) => nodes.iter().map(|n| n.name.clone()).collect(),
+ None => manager
+ .get_nodes("conn")
+ .await
+ .expect("get_nodes must work")
+ .into_iter()
+ .map(|n| n.node)
+ .collect(),
+ };
+ let mut proxied_reachable = 0usize;
+ for name in &node_names {
+ let path = format!("/nodes/{}/version", name);
+ match api_request(&client, &primary_url, Method::GET, &path, &auth, &[], None).await {
+ Ok(value) => {
+ proxied_reachable += 1;
+ eprintln!(
+ " primary-proxy /nodes/{}/version -> REACHABLE version={}",
+ name,
+ value["version"].as_str().unwrap_or("?")
+ );
+ }
+ Err(e) => eprintln!(" primary-proxy /nodes/{}/version -> FAILED: {}", name, e),
+ }
+ }
+
+ // Assert the primary node/endpoint is reachable.
+ assert!(
+ !candidates.is_empty(),
+ "no node candidates at all — cluster unreachable?"
+ );
+ let primary_target = candidates
+ .iter()
+ .find(|(_, is_primary)| *is_primary)
+ .map(|(u, _)| u.clone())
+ .unwrap_or_else(|| primary_url.clone());
+ let primary_result = api_request(&client, &primary_target, Method::GET, "/version", &auth, &[], None).await;
+ assert!(
+ primary_result.is_ok(),
+ "primary node {} must be reachable, got: {:?}",
+ primary_target,
+ primary_result.err()
+ );
+ eprintln!(
+ " primary node {} REACHABLE (asserted) — version={}",
+ primary_target,
+ primary_result.unwrap()["version"].as_str().unwrap_or("?")
+ );
+ eprintln!(
+ " direct reachability: {} of {} candidate URLs reachable; cluster-proxy: {} of {} nodes reachable",
+ reachable,
+ candidates.len(),
+ proxied_reachable,
+ node_names.len()
+ );
+ if !unreachable.is_empty() {
+ eprintln!(" NOTE (not asserted): unreachable candidates:");
+ for u in &unreachable {
+ eprintln!(" - {}", u);
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Test 3: failover to a configured fallback endpoint
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+#[ignore]
+async fn live_failover_to_fallback() {
+ let Some((url, user, pass)) = live_env() else {
+ eprintln!("{}", skip_reason());
+ return;
+ };
+ eprintln!("Live target: {} (user {})", url, user);
+ let (ticket, csrf) = match login(&url, &user, &pass).await {
+ Ok(v) => v,
+ Err(e) => panic!("login failed: {}", e),
+ };
+ let dir = match tempfile::tempdir() {
+ Ok(d) => d,
+ Err(e) => panic!("tempdir failed: {}", e),
+ };
+ let path = dir.path().join("connections.json");
+ let mut manager = ConnectionManager::new();
+ let config = ConnectionConfig {
+ id: "bad".to_string(),
+ name: "bad".to_string(),
+ // 127.0.0.1:1 is always connection-refused (nothing listens on port 1).
+ primary: EndpointConfig {
+ url: "https://127.0.0.1:1".to_string(),
+ node: None,
+ token: None,
+ },
+ fallbacks: vec![EndpointConfig {
+ url: url.trim_end_matches('/').to_string(),
+ node: None,
+ token: None,
+ }],
+ 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(user.to_string()),
+ nodes: vec![],
+ cluster_id: None,
+ };
+ manager
+ .add_connection(config, &path)
+ .await
+ .expect("connection 'bad' should be added");
+ manager
+ .set_session_ticket("bad", &ticket, &csrf)
+ .await
+ .expect("session ticket should be injected");
+
+ label("failover: get_nodes via fallback");
+ let nodes = match manager.get_nodes("bad").await {
+ Ok(n) => n,
+ Err(e) => panic!("get_nodes('bad') failed — fallback did not serve the request: {}", e),
+ };
+ eprintln!(" get_nodes succeeded via the real fallback endpoint; node count = {}", nodes.len());
+ for n in &nodes {
+ eprintln!(" node={} status={}", n.node, n.status);
+ }
+
+ let status = manager
+ .runtime_status("bad")
+ .expect("runtime_status should be readable");
+ eprintln!(" runtime_status('bad') = {:?}", status);
+ assert_eq!(status, "failover", "expected runtime_status 'failover' after the primary was refused, got {:?}", status);
+}
+
+// ---------------------------------------------------------------------------
+// Test 4: cluster identity is consistent across every discovered node
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+#[ignore]
+async fn live_cluster_identity_consistent_across_nodes() {
+ let Some((url, user, pass)) = live_env() else {
+ eprintln!("{}", skip_reason());
+ return;
+ };
+ eprintln!("Live target: {} (user {})", url, user);
+ let (mut manager, dir, ticket) = match manager_with_session(&url, &user, &pass).await {
+ Ok(v) => v,
+ Err(e) => panic!("setup failed: {}", e),
+ };
+ let path = dir.path().join("connections.json");
+
+ let discovered = match manager.discover_nodes("conn").await {
+ Ok(d) => d,
+ Err(e) => {
+ eprintln!(" discover_nodes FAILED: {}", e);
+ eprintln!(
+ " Cannot verify cluster identity: /cluster/status is permission-denied for this user\n (needs Sys.Audit on /), so per-node URLs cannot be derived and every node would report the\n same 403. There is no identity data to compare."
+ );
+ panic!(
+ "live_cluster_identity_consistent_across_nodes: cannot derive per-node cluster identity because /cluster/status is denied: {}",
+ e
+ );
+ }
+ };
+ eprintln!("discovered {} nodes", discovered.len());
+
+ label("per-node cluster identity");
+ let mut identities: Vec<(String, String, String)> = Vec::new(); // (name, id, name)
+ let mut direct_failures: Vec = Vec::new();
+ for (i, node) in discovered.iter().enumerate() {
+ let id = format!("node-{}", i);
+ let config = ConnectionConfig {
+ id: id.clone(),
+ name: id.clone(),
+ primary: EndpointConfig {
+ url: node.url.clone(),
+ node: Some(node.name.clone()),
+ 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(user.to_string()),
+ nodes: vec![],
+ cluster_id: None,
+ };
+ manager
+ .add_connection(config, &path)
+ .await
+ .expect("node connection should be added");
+ manager
+ .set_session_ticket(&id, &ticket, "")
+ .await
+ .expect("node session ticket should be injected");
+
+ match manager.get_cluster_status(&id).await {
+ Ok(status) => {
+ let node_count = status.nodes.as_ref().map(|n| n.len()).unwrap_or(0);
+ eprintln!(
+ " node={:<10} url={} cluster_id={:?} cluster_name={:?} node_count={}",
+ node.name, node.url, status.id, status.name, node_count
+ );
+ identities.push((node.name.clone(), status.id.clone(), status.name.clone()));
+ }
+ Err(e) => {
+ eprintln!(" node={:<10} url={} FAILED: {}", node.name, node.url, e);
+ direct_failures.push(e.to_string());
+ }
+ }
+ }
+
+ if identities.is_empty() {
+ // When every per-node attempt failed at the transport layer, the
+ // derived node URLs are not routable from this host (cluster-internal
+ // IPs). That is an environment limitation, not an identity mismatch:
+ // verify the cluster identity through the primary endpoint (every
+ // node-scoped request is forwarded by pveproxy to the same cluster)
+ // and skip the direct comparison.
+ let all_transport =
+ !direct_failures.is_empty() && direct_failures.iter().all(|f| f.contains("Cannot connect"));
+ if all_transport {
+ eprintln!(
+ " [skip] all {} per-node direct connections failed with transport errors — derived\n\
+ node URLs are not routable from this host. Verifying cluster identity through\n\
+ the primary endpoint instead.",
+ direct_failures.len()
+ );
+ let proxy_nodes = manager
+ .get_nodes("conn")
+ .await
+ .expect("get_nodes must still work through the primary");
+ match manager.get_cluster_status("conn").await {
+ Ok(status) => {
+ let node_count = status.nodes.as_ref().map(|n| n.len()).unwrap_or(0);
+ eprintln!(
+ " primary-proxy /cluster/status -> cluster_id={:?} cluster_name={:?} node_count={}",
+ status.id, status.name, node_count
+ );
+ assert!(
+ !status.id.is_empty(),
+ "cluster id from primary /cluster/status is empty — not a real cluster?"
+ );
+ eprintln!(
+ " primary-proxy per-node reachability: {} nodes served through the primary",
+ proxy_nodes.len()
+ );
+ eprintln!(
+ " CONSISTENT (via primary proxy): cluster id {:?} serves all {} nodes",
+ status.id,
+ proxy_nodes.len()
+ );
+ }
+ Err(e) => panic!("cluster identity via primary proxy failed: {}", e),
+ }
+ return;
+ }
+ assert!(
+ !identities.is_empty(),
+ "no node reported a cluster status — cannot compare cluster identity (failures: {:?})",
+ direct_failures
+ );
+ }
+ let (first_name, first_id, _) = &identities[0];
+ assert!(
+ !first_id.is_empty(),
+ "cluster id reported by node {} is empty — not a real cluster?",
+ first_name
+ );
+ for (name, cid, _) in identities.iter().skip(1) {
+ assert_eq!(
+ cid, first_id,
+ "cluster id mismatch: node {} reports {:?} but node {} reports {:?} — same-cluster merging would break",
+ name, cid, first_name, first_id
+ );
+ }
+ eprintln!(
+ " CONSISTENT: all {} nodes report cluster id {:?}",
+ identities.len(),
+ first_id
+ );
+}
+
diff --git a/src-tauri/tests/persistence.rs b/src-tauri/tests/persistence.rs
index ec89976..acaa381 100644
--- a/src-tauri/tests/persistence.rs
+++ b/src-tauri/tests/persistence.rs
@@ -485,5 +485,5 @@ async fn disconnect_clears_session_and_status() {
.await
.expect("reconnect after disconnect should succeed");
assert_eq!(result.status, "connected");
- mock.assert_hits(2);
+ mock.assert_calls(2);
}
diff --git a/src/App.tsx b/src/App.tsx
index 226d009..b703b58 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -7,6 +7,7 @@ import { VMDetail } from '@/components/vms/VMDetail'
import { ContainerList } from '@/components/vms/ContainerList'
import { NodesPage } from '@/components/nodes/NodesPage'
import { TaskList } from '@/components/tasks/TaskList'
+import { TaskStatusBar } from '@/components/tasks/TaskStatusBar'
import { BackupList } from '@/components/backups/BackupList'
import { CommandPalette } from '@/components/command/CommandPalette'
import { StorageOverview } from '@/components/storage/StorageOverview'
@@ -18,8 +19,8 @@ import { Server, AlertTriangle } from 'lucide-react'
import { useConnectionStore } from '@/stores/connectionStore'
import { useUIStore } from '@/stores/uiStore'
import { useWebSocket } from '@/hooks/useWebSocket'
-import { isTauri, loadConnections, connectToServer, getConnectionStatus, updateTrayMenu } from '@/lib/tauri'
-import { useEffect, useRef, useState } from 'react'
+import { isTauri, loadConnections, connectToServer, getConnectionStatus, getWebSocketURL, updateTrayMenu } from '@/lib/tauri'
+import { useEffect, useMemo, useRef, useState } from 'react'
import type { ProxmoxVM } from '@/types/proxmox'
const queryClient = new QueryClient({
@@ -121,6 +122,23 @@ function AppContent() {
)
}, [connections])
+ // Tray connection clicks switch the active connection (mirrored to the
+ // backend by the store so it persists across launches).
+ useEffect(() => {
+ if (!isTauri()) return
+ let unlisten: (() => void) | undefined
+ import('@tauri-apps/api/event')
+ .then(async ({ listen }) => {
+ unlisten = await listen('tray-connection-click', (event) => {
+ setActiveConnection(event.payload)
+ })
+ })
+ .catch(() => {})
+ return () => {
+ unlisten?.()
+ }
+ }, [setActiveConnection])
+
// Poll the backend connection status while a connection is active so the
// sidebar node list and failover state stay in sync with the cluster.
useEffect(() => {
@@ -157,7 +175,36 @@ function AppContent() {
}, [connectionsLoaded, connections.length, connectionDialogOpen])
// WebSocket integration – connects when a connection is active
- useWebSocket(activeConnectionId)
+ const { connect: connectRelay, disconnect: disconnectRelay } = useWebSocket(activeConnectionId)
+
+ // Start the backend event relay once a connection is connected, so task and
+ // node events invalidate queries in near real-time. Polling remains the
+ // fallback when the relay is not connected.
+ const activeStatus = useMemo(
+ () => connections.find((c) => c.id === activeConnectionId)?.status,
+ [connections, activeConnectionId],
+ )
+ useEffect(() => {
+ if (!isTauri() || !activeConnectionId) return
+ if (activeStatus !== 'connected' && activeStatus !== 'failover') {
+ disconnectRelay().catch(() => {})
+ return
+ }
+ let cancelled = false
+ const startRelay = async () => {
+ try {
+ const origin = await getWebSocketURL(activeConnectionId, '')
+ if (cancelled) return
+ await connectRelay(`${origin}/api2/json/events`)
+ } catch (err) {
+ console.error('[App] Failed to start event relay:', err)
+ }
+ }
+ startRelay()
+ return () => {
+ cancelled = true
+ }
+ }, [activeConnectionId, activeStatus, connectRelay, disconnectRelay])
// Global keyboard shortcut: Cmd/Ctrl+K to open command palette
useEffect(() => {
@@ -204,6 +251,7 @@ function AppContent() {
switch (viewName) {
case 'dashboard': handleNavigate({ type: 'dashboard' }); break
case 'vms': handleNavigate({ type: 'vms' }); break
+ case 'nodes': handleNavigate({ type: 'nodes' }); break
case 'tasks': handleNavigate({ type: 'tasks' }); break
case 'backups': handleNavigate({ type: 'backups' }); break
case 'storage': handleNavigate({ type: 'storage' }); break
@@ -226,12 +274,18 @@ function AppContent() {
/>
)
case 'nodes':
- return
+ return (
+ handleNavigate({ type: 'vm-detail', vm })}
+ />
+ )
case 'node-detail':
return (
handleNavigate({ type: 'vm-detail', vm })}
/>
)
case 'containers':
@@ -291,8 +345,18 @@ function AppContent() {
activeView={view.type}
onNavigate={(v) => handleNavigate(v as View)}
/>
-
- {renderMainContent()}
+
+ {activeConnectionId && (
+
+ handleNavigate({ type: 'tasks' })}
+ />
+
+ )}
+
+ {renderMainContent()}
+
(null)
const [confirmDeleteBackup, setConfirmDeleteBackup] = useState(null)
+ // Backups are storage-scoped on the backend, so the active filter must be
+ // passed through or only the default storage would ever be listed.
+ const { data: backups, isLoading: backupsLoading, error: backupsError } = useBackups(
+ connectionId,
+ storageFilter === 'all' ? undefined : storageFilter,
+ )
+
const storageOptions = useMemo(() => {
if (!storage) return []
return [...new Set(storage.map((s) => s.storage))].sort()
@@ -363,7 +369,12 @@ export function BackupList({ connectionId }: BackupListProps) {
confirmLabel="Delete"
isLoading={deleteBackupJob.isPending}
onConfirm={() => {
- if (confirmDeleteJob) deleteBackupJob.mutate({ id: confirmDeleteJob })
+ if (confirmDeleteJob) {
+ deleteBackupJob.mutate(
+ { id: confirmDeleteJob },
+ { onSettled: () => setConfirmDeleteJob(null) },
+ )
+ }
}}
/>
@@ -377,7 +388,12 @@ export function BackupList({ connectionId }: BackupListProps) {
confirmLabel="Delete"
isLoading={deleteBackup.isPending}
onConfirm={() => {
- if (confirmDeleteBackup) deleteBackup.mutate({ volid: confirmDeleteBackup })
+ if (confirmDeleteBackup) {
+ deleteBackup.mutate(
+ { volid: confirmDeleteBackup },
+ { onSettled: () => setConfirmDeleteBackup(null) },
+ )
+ }
}}
/>
diff --git a/src/components/command/CommandPalette.tsx b/src/components/command/CommandPalette.tsx
index e680d41..000a27b 100644
--- a/src/components/command/CommandPalette.tsx
+++ b/src/components/command/CommandPalette.tsx
@@ -321,7 +321,7 @@ export function CommandPalette({
},
)
}
- if (vm.status === 'stopped' || vm.status === 'paused') {
+ if (vm.status === 'stopped' || vm.status === 'paused' || vm.status === 'suspended') {
items.push({
id: `action-start-${vm.vmid}`,
label: `Start ${vm.name}`,
diff --git a/src/components/console/TerminalConsole.tsx b/src/components/console/TerminalConsole.tsx
index 878aaa9..926c45d 100644
--- a/src/components/console/TerminalConsole.tsx
+++ b/src/components/console/TerminalConsole.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import 'xterm/css/xterm.css'
+import { isTauri, startConsoleProxy, stopConsoleProxy } from '@/lib/tauri'
interface TerminalConsoleProps {
connectionId: string
@@ -16,8 +17,14 @@ export function TerminalConsole({ connectionId, node, vmid, onError, onConnected
const termRef = useRef(null)
const fitAddonRef = useRef(null)
const wsRef = useRef(null)
+ const sessionRef = useRef(null)
const cleanup = useCallback(() => {
+ if (sessionRef.current) {
+ const sid = sessionRef.current
+ sessionRef.current = null
+ stopConsoleProxy(sid).catch(() => {})
+ }
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
@@ -64,40 +71,22 @@ export function TerminalConsole({ connectionId, node, vmid, onError, onConnected
return
}
- // Get WebSocket URL for terminal proxy
+ // Resolve the WebSocket URL: in Tauri mode a Rust-side loopback proxy
+ // bridges the self-signed Proxmox WebSocket; in dev mode there is a
+ // mock shell below so the URL is unused.
let wsUrl: string
-
- try {
- const { isTauri, createTermProxy, getWebSocketURL } = await import('@/lib/tauri')
-
- if (isTauri()) {
- const [proxyInfo, baseUrl] = await Promise.all([
- createTermProxy(connectionId, node, vmid),
- getWebSocketURL(connectionId, node),
- ])
-
- wsUrl = `${baseUrl}/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=${proxyInfo.port}&ticket=${encodeURIComponent(proxyInfo.ticket)}`
- } else {
- // Dev mode: construct a mock URL
- wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=6100&ticket=mock-ticket`
- }
- } catch {
- // Fallback for dev mode
+ if (isTauri()) {
+ const info = await startConsoleProxy(connectionId, 'term', node, vmid)
+ if (cancelled) return
+ sessionRef.current = info.sessionId
+ wsUrl = info.url
+ } else {
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=6100&ticket=mock-ticket`
}
if (cancelled) return
- // In dev mode, show a mock terminal since we can't connect to real WebSocket
- let isTauriMode = false
- try {
- const { isTauri } = await import('@/lib/tauri')
- isTauriMode = isTauri()
- } catch {
- // not in tauri
- }
-
- if (!isTauriMode && !cancelled) {
+ if (!isTauri()) {
// Dev mode mock terminal
terminal.writeln('\x1b[1;32m╔══════════════════════════════════════════╗\x1b[0m')
terminal.writeln('\x1b[1;32m║ Clustri - Terminal Console ║\x1b[0m')
@@ -165,9 +154,7 @@ export function TerminalConsole({ connectionId, node, vmid, onError, onConnected
}
}
- if (cancelled) return
-
- // Production: connect via WebSocket
+ // Production: connect via the local proxy WebSocket
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
wsRef.current = ws
diff --git a/src/components/console/VNCConsole.tsx b/src/components/console/VNCConsole.tsx
index 7b65b3b..1505ab5 100644
--- a/src/components/console/VNCConsole.tsx
+++ b/src/components/console/VNCConsole.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback } from 'react'
+import { isTauri, startConsoleProxy, stopConsoleProxy } from '@/lib/tauri'
interface VNCConsoleProps {
connectionId: string
@@ -14,8 +15,14 @@ export function VNCConsole({ connectionId, node, vmid, onError, onConnected }: V
const containerRef = useRef(null)
const rfbRef = useRef(null)
const stateRef = useRef('connecting')
+ const sessionRef = useRef(null)
const cleanup = useCallback(() => {
+ if (sessionRef.current) {
+ const sid = sessionRef.current
+ sessionRef.current = null
+ stopConsoleProxy(sid).catch(() => {})
+ }
if (rfbRef.current) {
const rfb = rfbRef.current as { disconnect: () => void }
rfb.disconnect()
@@ -32,26 +39,17 @@ export function VNCConsole({ connectionId, node, vmid, onError, onConnected }: V
try {
stateRef.current = 'connecting'
- // Get WebSocket URL for VNC
+ // In Tauri mode the console stream goes through a Rust-side loopback
+ // proxy so self-signed Proxmox certificates are accepted; the returned
+ // local ws:// URL is handed to noVNC.
let wsUrl: string
-
- try {
- // Try real IPC first
- const { isTauri, createVNCProxy, getWebSocketURL } = await import('@/lib/tauri')
-
- if (isTauri()) {
- const [proxyInfo, baseUrl] = await Promise.all([
- createVNCProxy(connectionId, node, vmid),
- getWebSocketURL(connectionId, node),
- ])
-
- wsUrl = `${baseUrl}/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=${proxyInfo.port}&vncticket=${encodeURIComponent(proxyInfo.ticket)}`
- } else {
- // Dev mode: construct a mock URL
- wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=6000&vncticket=mock-ticket`
- }
- } catch {
- // Fallback for dev mode
+ if (isTauri()) {
+ const info = await startConsoleProxy(connectionId, 'vnc', node, vmid)
+ if (cancelled) return
+ sessionRef.current = info.sessionId
+ wsUrl = info.url
+ } else {
+ // Dev mode: construct a mock URL
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=6000&vncticket=mock-ticket`
}
diff --git a/src/components/dashboard/NodeHealthGrid.tsx b/src/components/dashboard/NodeHealthGrid.tsx
index 67db3a3..bab8164 100644
--- a/src/components/dashboard/NodeHealthGrid.tsx
+++ b/src/components/dashboard/NodeHealthGrid.tsx
@@ -1,7 +1,6 @@
import { useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
-import { AreaChart, Area, ResponsiveContainer } from 'recharts'
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
import { formatBytes, formatUptime } from '@/lib/format'
import { cn } from '@/lib/utils'
@@ -25,26 +24,11 @@ function getStatusLabel(status: 'online' | 'offline'): string {
return status === 'online' ? 'Online' : 'Offline'
}
-/** Generate deterministic mock sparkline data from node stats */
-function generateSparklineData(baseValue: number, points: number = 12): { value: number }[] {
- const data: { value: number }[] = []
- for (let i = 0; i < points; i++) {
- // Deterministic pseudo-random variation around the base value
- const seed = Math.sin(i * 2.1 + baseValue * 0.01) * 0.3
- const variation = baseValue * seed
- data.push({ value: Math.max(0, Math.min(1, baseValue + variation)) })
- }
- return data
-}
-
function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
const cpuPercent = node.maxcpu > 0 ? node.cpu : 0
const memPercent = node.maxmem > 0 ? node.mem / node.maxmem : 0
const diskPercent = node.maxdisk > 0 ? node.disk / node.maxdisk : 0
- const cpuData = useMemo(() => generateSparklineData(cpuPercent), [cpuPercent])
- const memData = useMemo(() => generateSparklineData(memPercent), [memPercent])
-
return (
@@ -59,54 +43,6 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
- {/* Sparklines */}
-