From 190fdb2573f5326ae2aaa47168f22f25415b29b2 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 9 Aug 2026 23:01:51 +0000 Subject: [PATCH] feat: add cluster discovery, failover, VM management, and console support --- README.md | 377 ++- package-lock.json | 20 + package.json | 4 +- src-tauri/.cargo/config.toml | 0 src-tauri/Cargo.lock | 288 ++ src-tauri/Cargo.toml | 7 + src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/gen/schemas/linux-schema.json | 2612 +++++++++++++++++ src-tauri/src/connection.rs | 2036 +++++++++++-- src-tauri/src/error.rs | 3 + src-tauri/src/lib.rs | 289 +- src-tauri/src/proxmox.rs | 54 + src-tauri/src/tls.rs | 218 ++ src-tauri/src/websocket.rs | 7 +- src-tauri/target/.rustc_info.json | 2 +- .../output-build-script-build-script-build | 2 - .../output-build-script-build-script-build | 2 - .../output-build-script-build-script-build | 2 - .../output-build-script-build-script-build | 2 - ...0af635abc.3ufr5450lp20sxhbw3b5lnbvs.rcgu.o | Bin 2560 -> 0 bytes ...script_build.a30a70663bab765a-cgu.0.rcgu.o | Bin 188848 -> 0 bytes ...script_build.a30a70663bab765a-cgu.1.rcgu.o | Bin 135328 -> 0 bytes .../build_script_build-19124a20af635abc.d | 4 +- ...3fe64eada.1cb4wof2950oslyk39975h0vc.rcgu.o | Bin 2560 -> 0 bytes ...script_build.3e289dc730a2dac7-cgu.0.rcgu.o | Bin 199744 -> 0 bytes .../build_script_build-28cd7e73fe64eada.d | 4 +- ...script_build.290c8a9cd7fd2ff3-cgu.0.rcgu.o | Bin 119664 -> 0 bytes ...4e4e81362.cb5aizg7jyp8yonp8exnihyxb.rcgu.o | Bin 2560 -> 0 bytes .../build_script_build-6dff9724e4e81362.d | 4 +- ...1ddfe8493.93o87lhgng2qq8bftj8op6z8i.rcgu.o | Bin 2560 -> 0 bytes ...script_build.adfdc0c4a054f8c7-cgu.0.rcgu.o | Bin 185664 -> 0 bytes .../build_script_build-e613c711ddfe8493.d | 4 +- .../deps/unicode_ident-8443eb632a3fbe4c.d | 6 +- src-tauri/tests/api_client.rs | 213 ++ src-tauri/tests/api_commands.rs | 570 ++++ src-tauri/tests/api_console.rs | 168 ++ src-tauri/tests/api_disk_network.rs | 465 +++ src-tauri/tests/api_snapshots_backups.rs | 448 +++ src-tauri/tests/cluster_flow.rs | 467 +++ src-tauri/tests/discovery.rs | 289 ++ src-tauri/tests/failover.rs | 202 ++ src-tauri/tests/persistence.rs | 489 +++ src-tauri/tests/tls.rs | 186 ++ src/App.tsx | 175 +- src/components/ErrorBoundary.tsx | 16 +- src/components/backups/BackupList.tsx | 312 +- .../backups/dialogs/CreateBackupJobDialog.tsx | 4 +- .../backups/dialogs/EditBackupJobDialog.tsx | 4 +- src/components/command/CommandPalette.tsx | 10 +- .../connections/ConnectionDialog.tsx | 565 +++- src/components/dashboard/ActivityFeed.tsx | 62 +- src/components/dashboard/NodeHealthGrid.tsx | 89 +- src/components/dashboard/QuickActions.tsx | 35 +- src/components/dashboard/ResourceGauge.tsx | 41 +- src/components/layout/Dashboard.tsx | 93 +- src/components/layout/Sidebar.tsx | 149 +- src/components/nodes/NodeDetail.tsx | 69 +- src/components/nodes/NodesPage.tsx | 161 + src/components/settings/ConnectionManager.tsx | 100 +- src/components/settings/SettingsPage.tsx | 4 +- src/components/settings/ThemeSwitcher.tsx | 6 +- src/components/storage/StorageCard.tsx | 20 +- src/components/storage/StorageDetail.tsx | 80 +- src/components/storage/StorageOverview.tsx | 27 +- src/components/tasks/TaskList.tsx | 105 +- src/components/tasks/TaskStatusBar.tsx | 8 +- src/components/ui/button.tsx | 17 +- src/components/ui/card.tsx | 13 +- src/components/ui/confirm-dialog.tsx | 63 + src/components/ui/dialog.tsx | 6 +- src/components/ui/empty-state.tsx | 24 + src/components/ui/input.tsx | 2 +- src/components/ui/label.tsx | 2 +- src/components/ui/scroll-area.tsx | 2 +- src/components/ui/skeleton.tsx | 48 + src/components/ui/status-badge.tsx | 18 +- src/components/ui/tabs.tsx | 4 +- src/components/ui/toast.tsx | 24 +- src/components/vms/ContainerList.tsx | 60 +- src/components/vms/VMDetail.tsx | 34 +- src/components/vms/VMList.tsx | 62 +- src/components/vms/dialogs/AddDiskDialog.tsx | 1 + src/components/vms/dialogs/AddNICDialog.tsx | 3 +- .../vms/dialogs/CreateSnapshotDialog.tsx | 3 +- src/components/vms/dialogs/EditNICDialog.tsx | 3 +- src/components/vms/dialogs/MigrateDialog.tsx | 9 +- src/components/vms/dialogs/MoveDiskDialog.tsx | 1 + .../vms/dialogs/ResizeDiskDialog.tsx | 1 + src/components/vms/tabs/ConsoleTab.tsx | 12 +- src/components/vms/tabs/DisksTab.tsx | 127 +- src/components/vms/tabs/HardwareTab.tsx | 20 +- src/components/vms/tabs/NetworkTab.tsx | 153 +- src/components/vms/tabs/OverviewTab.tsx | 47 +- src/components/vms/tabs/SnapshotsTab.tsx | 148 +- src/hooks/useProxmox.ts | 103 +- src/index.css | 188 +- src/lib/tauri.ts | 124 +- src/main.tsx | 2 + src/stores/connectionStore.ts | 14 +- src/types/connection.ts | 36 + 100 files changed, 11351 insertions(+), 1604 deletions(-) create mode 100644 src-tauri/.cargo/config.toml create mode 100644 src-tauri/gen/schemas/linux-schema.json create mode 100644 src-tauri/src/tls.rs delete mode 100644 src-tauri/target/debug/.fingerprint/libc-19124a20af635abc/output-build-script-build-script-build delete mode 100644 src-tauri/target/debug/.fingerprint/proc-macro2-28cd7e73fe64eada/output-build-script-build-script-build delete mode 100644 src-tauri/target/debug/.fingerprint/quote-6dff9724e4e81362/output-build-script-build-script-build delete mode 100644 src-tauri/target/debug/.fingerprint/serde_core-e613c711ddfe8493/output-build-script-build-script-build delete mode 100644 src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.3ufr5450lp20sxhbw3b5lnbvs.rcgu.o delete mode 100644 src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.build_script_build.a30a70663bab765a-cgu.0.rcgu.o delete mode 100644 src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.build_script_build.a30a70663bab765a-cgu.1.rcgu.o delete mode 100644 src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.1cb4wof2950oslyk39975h0vc.rcgu.o delete mode 100644 src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.build_script_build.3e289dc730a2dac7-cgu.0.rcgu.o delete mode 100644 src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.build_script_build.290c8a9cd7fd2ff3-cgu.0.rcgu.o delete mode 100644 src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.cb5aizg7jyp8yonp8exnihyxb.rcgu.o delete mode 100644 src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.93o87lhgng2qq8bftj8op6z8i.rcgu.o delete mode 100644 src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.build_script_build.adfdc0c4a054f8c7-cgu.0.rcgu.o create mode 100644 src-tauri/tests/api_client.rs create mode 100644 src-tauri/tests/api_commands.rs create mode 100644 src-tauri/tests/api_console.rs create mode 100644 src-tauri/tests/api_disk_network.rs create mode 100644 src-tauri/tests/api_snapshots_backups.rs create mode 100644 src-tauri/tests/cluster_flow.rs create mode 100644 src-tauri/tests/discovery.rs create mode 100644 src-tauri/tests/failover.rs create mode 100644 src-tauri/tests/persistence.rs create mode 100644 src-tauri/tests/tls.rs create mode 100644 src/components/nodes/NodesPage.tsx create mode 100644 src/components/ui/confirm-dialog.tsx create mode 100644 src/components/ui/empty-state.tsx create mode 100644 src/components/ui/skeleton.tsx diff --git a/README.md b/README.md index 94f2f96..3b7d5dd 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,128 @@ # ProxmoxDesktop -A cross-platform desktop application for managing Proxmox VE servers and clusters. Built with Tauri, React, and TypeScript. +A cross-platform desktop client for managing Proxmox VE servers and clusters. The backend is Rust on Tauri 2; the UI is React 19 with TypeScript, Vite, and Tailwind CSS v4. ## Features -### Phase 1: Foundation (Current) -- ✅ Multi-server connection management -- ✅ API token authentication -- ✅ Self-signed certificate handling (TOFU) -- ✅ OS keyring integration for secure credential storage -- ✅ Dashboard with cluster overview -- ✅ Node status monitoring -- ✅ Real-time resource usage tracking +### Connections and authentication -### Planned Features -- VM & Container lifecycle management (start, stop, reboot, shutdown) -- Console access (noVNC for VMs, xterm.js for containers) -- Disk management (add, resize, remove, move) -- Network interface management -- Backup job management and restore -- Snapshot management -- System tray integration -- Command palette (Cmd/Ctrl+K) -- Automatic failover with primary/fallback endpoints +- Manage multiple simultaneous connections to servers and clusters. +- Authenticate with an API token or with a username and password. Password mode uses ticket-based auth (PVEAuthCookie plus CSRF token). +- Credentials live in the OS keyring. API tokens never touch disk. +- Connections persist to `{app-config-dir}/proxmoxdesktop/connections.json`. The last active connection reconnects on launch. + +### Cluster support + +- On connect, the backend queries `/nodes` and `/cluster/status` and derives an endpoint URL for every cluster node, keeping the connection's scheme and port (default 8006) with the node's cluster IP (or name) as the host. The connected node is marked primary and the node list is stored with the connection. +- API requests route through an ordered, deduplicated endpoint list: the primary endpoint, configured fallbacks, then the discovered nodes. A connection refused, timeout, DNS, or transport failure retries the request on the next endpoint. Status is `connected` when the primary serves, `failover` while a fallback serves, and `failed` when every endpoint is unreachable. +- The frontend polls connection status every 10 seconds. The sidebar lists the cluster nodes with a live status dot per node (primary marked), shows a "Failover: {url}" note with an amber banner while on a fallback, and "Offline" when disconnected or failed. +- Connecting to another node of the same cluster (matched by cluster id) merges instead of duplicating: the new node's URL joins the existing connection as a fallback endpoint, the duplicate connection is removed, the active connection switches, and a toast explains what happened. + +### Certificate handling (TOFU) + +- On first connect, the app captures the server's TLS certificate and shows its SHA-256 fingerprint for confirmation. +- The fingerprint is pinned and verified on every subsequent connect. A per-connection "accept anyway" option covers cases where you knowingly replaced the server certificate. + +### Dashboard and monitoring + +- Cluster overview with a node health grid, resource gauges, an activity feed, and quick actions. +- Per-node view: resource usage, system information, and the VMs and containers on the node. + +### VMs and containers (QEMU and LXC) + +- List and filter VMs and containers. +- Lifecycle actions: start, stop, shutdown, reboot, suspend, resume, migrate. +- VM detail with overview, hardware, disks, network, snapshots, and console tabs. + +### Storage and networking + +- Disk management: list, add, resize, remove, move. +- Network interface management: list, add, edit, remove. +- Storage overview and per-storage detail. + +### Snapshots and backups + +- Snapshot list, create, delete, rollback. +- Backup jobs: list, create, edit, delete, run now. Restore or delete existing backups. + +### Console + +- noVNC for VMs and an xterm.js terminal for containers, with fullscreen and Ctrl+Alt+Del support. + +### Tasks and events + +- Task list with status filtering and a running-task indicator. +- A WebSocket relay in the backend forwards task, node, and VM events to the frontend. Polling remains the fallback. + +### Interface + +- Command palette (Ctrl/Cmd+K). +- System tray with a quick menu: show/hide, connection list, quit. +- Dark-first design with Geist for UI text, JetBrains Mono for data, and a single muted-orange accent. Light, dark, and system themes. ## Tech Stack ### Frontend -- **React 19** - UI framework -- **TypeScript** - Type safety -- **Vite** - Build tool and dev server -- **Tailwind CSS** - Utility-first styling -- **shadcn/ui** - Component library (Radix UI + Tailwind) -- **TanStack Query** - Server state management -- **Zustand** - Client state management -- **Lucide React** - Icons -### Backend (Tauri) -- **Tauri 2** - Desktop app framework -- **Rust** - Backend logic -- **reqwest** - HTTP client with TLS support -- **keyring** - OS keyring integration -- **tokio** - Async runtime +- React 19, TypeScript +- Vite, Tailwind CSS v4 +- Radix UI-based components (shadcn-style) +- TanStack Query (server state), Zustand (client state) +- lucide-react icons, Recharts gauges, noVNC, xterm.js + +### Backend + +- Tauri 2 +- reqwest with rustls for HTTP, keyring for the OS keyring, tokio for async +- rustls, x509-cert, and sha2 for certificate capture and fingerprinting +- tokio-tungstenite for the WebSocket relay + +### Development and testing + +- httpmock, rcgen, and tempfile for Rust tests +- oxlint for frontend linting + +## Architecture + +### Frontend to backend + +The React frontend calls Rust commands over Tauri IPC. The backend proxies them to the Proxmox API over HTTPS. + +``` +React component + → Tauri invoke + → Rust command + → Proxmox API ({base}/api2/json{path}) +``` + +### API core + +A Rust `ConnectionManager` owns one HTTP client per connection. A shared `api_request` core builds the `{base}/api2/json{path}` URL, injects auth (`PVEAPIToken` header for token mode; `PVEAuthCookie` and `CSRFPreventionToken` for password mode), unwraps the `{data}` envelope, and maps errors to the frontend. + +The same core routes each request through an ordered, deduplicated endpoint list: the primary endpoint, configured fallbacks, then the discovered cluster nodes. On a connect, timeout, DNS, or transport failure it retries on the next endpoint, and the connection's runtime status tracks whether the primary (`connected`), a fallback (`failover`), or no endpoint (`failed`) served the last request. + +Tauri commands cover: connection add/remove/update/load, connect/disconnect, set active; password and token login, logout, stored credentials; certificate info and trust; nodes, VMs, storage, storage content and detail, tasks, cluster status; QEMU and LXC lifecycle and migration; disks, NICs, snapshots; VNC and terminal proxies; websocket URL; backup jobs and restore; and the tray menu. + +### TLS + +Proxmox nodes typically run self-signed certificates, so the transport accepts them and the app enforces trust itself. On first connect the certificate is captured in `src-tauri/src/tls.rs`, its SHA-256 fingerprint is shown for confirmation, and the fingerprint is pinned and checked on every later connect. + +Fingerprint pinning runs at connect time against the node you connected to. Fallback and discovered endpoints are used only in degraded failover mode and get no pin check of their own; the transport already accepts their self-signed certificates. This is a deliberate trade-off to keep failover automatic in home-lab setups. + +### Real-time events + +A WebSocket manager connects to the Proxmox event API and relays task, node, and VM events as Tauri events. The frontend falls back to polling when the socket is not connected. + +### State management + +TanStack Query handles server state (fetching, caching, refetching). Zustand handles client state (active connection, UI, preferences). ## Prerequisites -### System Dependencies +### System dependencies #### Linux (Ubuntu/Debian) + ```bash sudo apt update sudo apt install -y \ @@ -64,35 +139,30 @@ sudo apt install -y \ ``` #### macOS -```bash -# Install Xcode Command Line Tools -xcode-select --install -# Install Homebrew dependencies +```bash +xcode-select --install brew install rust ``` #### Windows -```powershell -# Install Visual Studio Build Tools -# Download from: https://visualstudio.microsoft.com/visual-cpp-build-tools/ -# Select "Desktop development with C++" -# Install Rust via rustup -winget install Rustlang.Rustup -``` +- Install Visual Studio Build Tools and select "Desktop development with C++". +- Install Rust via rustup: `winget install Rustlang.Rustup` ### Node.js + +Node.js 20 or newer (nvm recommended): + ```bash -# Install Node.js 20+ via nvm (recommended) curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20 nvm use 20 ``` ### Rust + ```bash -# Install Rust curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env ``` @@ -100,166 +170,163 @@ source $HOME/.cargo/env ## Installation ```bash -# Clone the repository git clone cd ProxmoxDesktop - -# Install Node.js dependencies npm install - -# Install Tauri CLI globally (optional) -npm install -g @tauri-apps/cli ``` +The Tauri CLI is a devDependency, so `npm run tauri ...` works without a global install. + ## Development -### Run in Development Mode +### Run in the browser (mock data) ```bash -# Start the Vite dev server npm run dev +``` -# In a separate terminal, run the Tauri app +Starts the Vite dev server. The frontend runs in a browser with mock data and no Tauri backend. Useful for UI work. + +### Run the desktop app + +```bash npm run tauri dev ``` -The app will automatically reload when you make changes to the frontend or backend code. +Starts the full app. Frontend and backend changes reload automatically. -### Build for Production +### Build ```bash -# Build the application -npm run tauri build +npm run build # type-check and bundle the frontend +npm run tauri build # production bundle in src-tauri/target/release/bundle/ ``` -The built application will be in `src-tauri/target/release/bundle/`. - -### Run Tests +### Lint ```bash -# Run linter npm run lint - -# Type check -npm run tsc --noEmit ``` +## Testing + +```bash +cargo test +``` + +64 Rust tests cover the API client (against mocked HTTP responses via httpmock), TLS capture and pinning, connection persistence, and every endpoint group. The frontend is exercised in the browser mock mode described above. + +### Verifying against a real server + +The backend was developed against the documented Proxmox VE API using mocked HTTP responses. There is no live-server test suite yet. To verify against real infrastructure: + +1. Run `cargo test`. +2. Run `npm run tauri dev`. +3. Add a connection to a real Proxmox node (`https://host:8006`). +4. Confirm the certificate fingerprint matches the one shown in the Proxmox web UI (Datacenter → Options, or the node's `/etc/pve/local/pve-ssl.pem`). +5. Spot-check VM lifecycle actions, storage, backups, and the console. + +Cluster discovery, failover, and same-cluster merging are covered by Rust tests using mocked HTTP (httpmock) and connection-refused simulations on closed ports. To verify them on real hardware, connect to one node of a multi-node cluster and confirm the sidebar lists the other nodes. Stop the connected node's API service, or block its port 8006 with a firewall rule, and check that the connection flips to failover and data keeps loading. + +## Configuration + +### Proxmox API token + +1. In the Proxmox web UI, go to Datacenter → Permissions → API Tokens. +2. Click Add. +3. Select a user (for example, `root@pam`). +4. Enter a token ID (for example, `desktop`). +5. Uncheck Privilege Separation for full access. +6. Copy the token. It looks like `user@realm!tokenid=secret`. + +### Connection settings + +When adding a connection you need: + +- **Name**: a friendly name for the connection +- **Server URL**: for example `https://192.168.1.10:8006` +- **Auth**: an API token, or a username and password (stored in the OS keyring; sessions use ticket auth) + ## Project Structure ``` ProxmoxDesktop/ ├── src/ # React frontend │ ├── components/ -│ │ ├── ui/ # shadcn/ui components -│ │ ├── layout/ # Layout components (Sidebar, Dashboard) -│ │ └── connections/ # Connection management UI -│ ├── hooks/ # React Query hooks -│ ├── stores/ # Zustand stores -│ ├── lib/ # Utilities and Tauri IPC +│ │ ├── ui/ # base components (buttons, dialogs, toasts) +│ │ ├── layout/ # sidebar, dashboard shell +│ │ ├── connections/ # connection manager and dialog +│ │ ├── dashboard/ # node health grid, gauges, activity feed, quick actions +│ │ ├── nodes/ # node list and detail +│ │ ├── vms/ # VM/container lists, detail tabs +│ │ ├── console/ # noVNC and xterm consoles +│ │ ├── storage/ # storage overview and detail +│ │ ├── backups/ # backup jobs and backups +│ │ ├── tasks/ # task list and status bar +│ │ ├── settings/ # settings page, theme switcher +│ │ └── command/ # command palette +│ ├── hooks/ # useProxmox, useWebSocket (TanStack Query) +│ ├── stores/ # Zustand stores (connections, UI) +│ ├── lib/ # tauri.ts IPC bindings, format helpers │ ├── types/ # TypeScript types -│ ├── App.tsx # Main app component -│ ├── main.tsx # Entry point -│ └── index.css # Global styles +│ ├── App.tsx +│ ├── main.tsx +│ └── index.css ├── src-tauri/ # Tauri/Rust backend │ ├── src/ -│ │ ├── main.rs # Entry point -│ │ ├── lib.rs # Tauri commands and app setup -│ │ ├── connection.rs # Connection manager +│ │ ├── main.rs # binary entry point +│ │ ├── lib.rs # Tauri commands, app setup, tray menu +│ │ ├── connection.rs # ConnectionManager, api_request core │ │ ├── proxmox.rs # Proxmox API types -│ │ └── error.rs # Error handling -│ ├── Cargo.toml # Rust dependencies -│ └── tauri.conf.json # Tauri configuration -├── package.json # Node.js dependencies -├── vite.config.ts # Vite configuration -└── tsconfig.json # TypeScript configuration +│ │ ├── tls.rs # certificate capture and TOFU pinning +│ │ ├── websocket.rs # WebSocket event relay +│ │ └── error.rs # error mapping +│ ├── tests/ # integration tests (httpmock, rcgen, tempfile) +│ │ ├── api_client.rs +│ │ ├── api_commands.rs +│ │ ├── api_console.rs +│ │ ├── api_disk_network.rs +│ │ ├── api_snapshots_backups.rs +│ │ ├── persistence.rs +│ │ └── tls.rs +│ ├── capabilities/ # Tauri permissions +│ ├── Cargo.toml +│ └── tauri.conf.json +├── package.json +├── vite.config.ts +└── tsconfig.json ``` -## Architecture - -### Frontend-Backend Communication - -The frontend communicates with the Rust backend via Tauri's IPC (Inter-Process Communication): - -``` -React Component - ↓ -Tauri IPC (invoke) - ↓ -Rust Backend - ↓ -Proxmox API (HTTPS) -``` - -### Connection Management - -The app supports multiple simultaneous connections to Proxmox servers/clusters: -- Each connection has a primary endpoint and optional fallback endpoints -- Automatic failover when the primary endpoint is unreachable -- Credentials stored securely in OS keyring -- Certificate fingerprints cached for TOFU (Trust On First Use) - -### State Management - -- **TanStack Query**: Server state (API data, caching, refetching) -- **Zustand**: Client state (UI state, active connection, preferences) - -## Configuration - -### Proxmox API Token - -To generate an API token in Proxmox: -1. Go to Datacenter → Permissions → API Tokens -2. Click "Add" -3. Select a user (e.g., root@pam) -4. Enter a Token ID (e.g., "desktop") -5. Uncheck "Privilege Separation" for full access -6. Copy the token (format: `user@realm!tokenid=secret`) - -### Connection Settings - -When adding a connection, you'll need: -- **Connection Name**: A friendly name for the connection -- **Server URL**: The Proxmox server URL (e.g., `https://192.168.1.10:8006`) -- **API Token**: The API token generated above - ## Troubleshooting -### Linux: Missing System Dependencies +### Linux: missing system dependencies -If you see errors about missing libraries: ```bash sudo apt install -y libwebkit2gtk-4.1-dev build-essential libssl-dev ``` -### macOS: Code Signing Issues +### macOS: code signing -For development, you may need to allow the app in System Preferences → Security & Privacy. +For development builds, allow the app in System Settings → Privacy & Security when prompted. -### Windows: Build Errors +### Windows: build errors -Make sure you have: -- Visual Studio Build Tools with "Desktop development with C++" -- WebView2 (usually pre-installed on Windows 10/11) +Make sure you have Visual Studio Build Tools with "Desktop development with C++" and WebView2 (preinstalled on Windows 10 and 11). -### Self-Signed Certificates +### Self-signed certificates -The app will prompt you to trust self-signed certificates on first connection. The certificate fingerprint is stored for future connections. +On first connect the app shows the server certificate's SHA-256 fingerprint. Compare it against the fingerprint in the Proxmox web UI before trusting it. If the certificate changes later, the app blocks the connection until you confirm the new fingerprint. Use the "accept anyway" option only when you intentionally replaced the server certificate. ## Contributing -Contributions are welcome! Please: -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Run `npm run lint` and fix any issues -5. Submit a pull request +Contributions are welcome. Please: + +1. Fork the repository. +2. Create a feature branch. +3. Make your changes. +4. Run `npm run lint` and `cargo test`, and fix any issues. +5. Submit a pull request. ## License MIT - -## Acknowledgments - -- [Proxmox VE](https://www.proxmox.com/en/proxmox-ve) - The amazing virtualization platform -- [Tauri](https://tauri.app/) - Build smaller, faster, more secure desktop apps -- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components diff --git a/package-lock.json b/package-lock.json index 11eca02..191deb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "proxmoxdesktop", "version": "0.1.0", "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@novnc/novnc": "^1.7.0", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", @@ -561,6 +563,24 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/package.json b/package.json index 238eed9..14d5885 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "tauri": "tauri" }, "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@novnc/novnc": "^1.7.0", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", @@ -37,13 +39,13 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", - "esbuild": "^0.28.0", "@tauri-apps/cli": "^2.11.4", "@types/node": "^24.13.2", "@types/novnc": "^0.0.27", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "esbuild": "^0.28.0", "oxlint": "^1.71.0", "tailwindcss": "^4.3.3", "typescript": "~6.0.2", diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 64bfd17..7c63c01 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,48 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f" +dependencies = [ + "async-lock", + "event-listener", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atk" version = "0.18.2" @@ -849,6 +891,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1005,6 +1067,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.33" @@ -1317,6 +1385,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1329,6 +1416,30 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.4.1" @@ -1396,6 +1507,46 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpmock" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4888a4d02d8e1f92ffb6b4965cf5ff56dda36ef41975f41c6fa0f6bde78c4e" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-trait", + "base64 0.22.1", + "bytes", + "crossbeam-utils", + "form_urlencoded", + "futures-timer", + "futures-util", + "headers", + "http", + "http-body-util", + "hyper", + "hyper-util", + "path-tree", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "stringmetrics", + "tabwriter", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1406,9 +1557,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1865,6 +2018,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2286,6 +2445,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2309,6 +2474,25 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "path-tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136" +dependencies = [ + "smallvec", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2526,7 +2710,10 @@ version = "0.1.0" dependencies = [ "futures-util", "hex", + "httpmock", "keyring", + "percent-encoding", + "rcgen", "reqwest 0.12.28", "rustls", "rustls-pemfile", @@ -2536,8 +2723,10 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-shell", + "tempfile", "thiserror 2.0.19", "tokio", + "tokio-rustls", "tokio-tungstenite", "url", "uuid", @@ -2692,6 +2881,19 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2862,6 +3064,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.42" @@ -3118,6 +3333,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_regex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafc8d0c5330cecff10f16b459b479fd9acaa5b4acd7167301414e21b0057012" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3298,6 +3523,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.3" @@ -3414,6 +3645,12 @@ dependencies = [ "quote", ] +[[package]] +name = "stringmetrics" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765" + [[package]] name = "strsim" version = "0.11.1" @@ -3502,6 +3739,15 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tabwriter" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" +dependencies = [ + "unicode-width", +] + [[package]] name = "tao" version = "0.35.3" @@ -3809,6 +4055,19 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3997,6 +4256,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -4172,10 +4432,23 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4298,6 +4571,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -5174,6 +5453,15 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6b730fc..b1ab6e6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,12 +20,19 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "linux- uuid = { version = "1", features = ["v4"] } rustls = "0.23" rustls-pemfile = "2" +tokio-rustls = "0.26" x509-cert = "0.2" sha2 = "0.10" hex = "0.4" tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" url = "2" +percent-encoding = "2" + +[dev-dependencies] +httpmock = "0.8" +rcgen = "0.13" +tempfile = "3" [features] default = ["custom-protocol"] diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 9e26dfe..77eb0c2 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for ProxmoxDesktop","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 diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json new file mode 100644 index 0000000..d1e5361 --- /dev/null +++ b/src-tauri/gen/schemas/linux-schema.json @@ -0,0 +1,2612 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "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": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "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." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "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 a830b78..c161a7b 100644 --- a/src-tauri/src/connection.rs +++ b/src-tauri/src/connection.rs @@ -1,19 +1,372 @@ use crate::error::Error; use crate::proxmox::{ - AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterStatus, + AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterNode, ClusterStatus, CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot, Storage, StorageContent, StorageDetail, Task, VM, }; -use crate::{CertificateInfo, ConnectionConfig, LoginResult, TermProxyResponse, VNCProxyResponse}; -use reqwest::Client; +use crate::{ + CertificateInfo, ConnectResult, ConnectionConfig, ConnectionStatusInfo, DiscoveredNode, + EndpointConfig, LoginResult, TermProxyResponse, VNCProxyResponse, +}; +use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use reqwest::{Client, Method}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::Path; +use std::sync::Mutex; +use url::Url; + +/// Characters left unencoded when percent-encoding a volume id into a URL +/// path. This mirrors Proxmox's own `uri_encode` behavior: everything except +/// the RFC 3986 unreserved characters (`A-Za-z0-9-._~`) is encoded, so the +/// `:` and `/` in a volid like `local:backup/vzdump-qemu-100-...` become +/// `%3A` and `%2F`. +const VOLID_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~'); + +/// The result of loading persisted connections from disk. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadResult { + pub active_connection_id: Option, + pub connections: Vec, +} + +/// The on-disk shape of [`crate::ConnectionConfig`]s. Tokens are stripped +/// before writing, so the file never contains secrets. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersistedState { + active_connection_id: Option, + connections: Vec, +} + +/// The authentication mode used by a [`Connection`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMode { + Token, + Password, +} + +/// Authentication material for a Proxmox API request. +/// +/// Token mode uses `token` as a `PVEAPIToken` header; password mode uses +/// `ticket` as a `PVEAuthCookie` (plus `csrf_token` for non-GET requests). +#[derive(Debug, Clone)] +pub struct AuthContext { + pub mode: AuthMode, + pub token: Option, + pub ticket: Option, + pub csrf_token: Option, +} + +/// Builds the full Proxmox API URL for a request. +/// +/// `path` must start with `/` (e.g. `/nodes`); the request targets +/// `{base_url}/api2/json{path}`. A trailing slash on `base_url` is stripped. +fn build_api_url(base_url: &str, path: &str) -> crate::Result { + let base = base_url.trim_end_matches('/'); + let full = format!("{}/api2/json{}", base, path); + Url::parse(&full) + .map_err(|e| Error::InvalidUrl(format!("Invalid Proxmox API URL '{}': {}", full, e))) +} + +/// Deserializes an API response payload into `T`, mapping a parse failure to a +/// [`crate::Error::SerializationError`] that names the endpoint so the +/// mismatch is easy to diagnose. +fn parse_api(endpoint: &str, data: serde_json::Value) -> crate::Result +where + T: serde::de::DeserializeOwned, +{ + serde_json::from_value(data).map_err(|e| { + Error::SerializationError(format!( + "Failed to parse {} response from Proxmox: {}", + endpoint, e + )) + }) +} + +/// Derives a cluster node's endpoint URL from the connection's primary URL. +/// +/// The scheme and port of the primary URL are preserved (an explicit port is +/// kept verbatim; a missing one defaults to `8006`, pveproxy's port for both +/// `http` and `https`). The host is replaced with the node's cluster IP when +/// one is known, falling back to the node name. An unparseable primary URL +/// falls back to a best-effort `https://{host}:8006`. +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)), + Err(_) => format!("https://{}:8006", host), + } +} + +/// Sends an authenticated request to the Proxmox VE JSON API. +/// +/// On success the `data` field of the standard `{ "data": ... }` response +/// envelope is returned. If the response body is not JSON or has no `data` +/// key, the raw value is returned instead of failing. On a non-success status +/// the error message reported by the server is surfaced as +/// [`Error::ApiError`]. +pub async fn api_request( + client: &Client, + base_url: &str, + method: Method, + path: &str, + auth: &AuthContext, + query: &[(&str, String)], + form: Option<&[(&str, String)]>, +) -> crate::Result { + let needs_csrf = method != Method::GET; + + let mut url = build_api_url(base_url, path)?; + { + let mut pairs = url.query_pairs_mut(); + for (key, value) in query { + pairs.append_pair(key, value); + } + } + + let mut request = client.request(method, url); + match auth.mode { + AuthMode::Token => { + let token = auth + .token + .as_deref() + .ok_or_else(|| Error::InvalidCredentials("No API token configured".to_string()))?; + request = request.header("Authorization", format!("PVEAPIToken={}", token)); + } + AuthMode::Password => { + let ticket = auth + .ticket + .as_deref() + .ok_or_else(|| Error::AuthError("Not logged in: no session ticket".to_string()))?; + request = request.header("Cookie", format!("PVEAuthCookie={}", ticket)); + if needs_csrf { + let csrf = auth + .csrf_token + .as_deref() + .ok_or_else(|| Error::AuthError("Not logged in: no CSRF token".to_string()))?; + request = request.header("CSRFPreventionToken", csrf); + } + } + } + + if let Some(fields) = form { + request = request.form(fields); + } + + let response = request.send().await.map_err(|e| { + if e.is_connect() || e.is_timeout() || e.is_request() { + // Transport-level failures (connection refused, timeouts, DNS + // resolution and other pre-response send errors) are surfaced as + // `ConnectionFailed` so the caller can distinguish them from real + // authentication failures and fail over to another endpoint. + Error::ConnectionFailed(format!("Cannot connect to server: {}", e)) + } else { + Error::HttpError(e) + } + })?; + + let status = response.status(); + let text = response.text().await.map_err(Error::HttpError)?; + let body: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|_| { + if text.is_empty() { + serde_json::Value::Null + } else { + serde_json::Value::String(text) + } + }); + + 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())); + return Err(Error::ApiError(message)); + } + + Ok(body.get("data").cloned().unwrap_or(body)) +} struct Connection { config: ConnectionConfig, client: Client, - ticket: Option, - csrf_token: Option, - current_endpoint_index: usize, + ticket: Mutex>, + csrf_token: Mutex>, + current_endpoint_index: Mutex, + runtime_status: Mutex, +} + +impl Connection { + /// Stores the session (ticket + CSRF token) in memory for password-mode + /// authentication. + fn set_session(&self, ticket: String, csrf_token: String) { + if let Ok(mut guard) = self.ticket.lock() { + *guard = Some(ticket); + } + if let Ok(mut guard) = self.csrf_token.lock() { + *guard = Some(csrf_token); + } + } + + /// Resolves the authentication context for this connection. + /// + /// Token mode reads the token from the connection config, falling back to + /// the keyring. Password mode uses the in-memory session, loading the + /// ticket/CSRF token from the keyring and caching them if absent. + fn auth_context(&self) -> crate::Result { + if self.config.auth_mode == "token" { + let token = match self.config.primary.token.as_deref() { + Some(token) if !token.is_empty() => Some(token.to_string()), + _ => self.keyring_secret("token")?, + }; + Ok(AuthContext { + mode: AuthMode::Token, + token, + ticket: None, + csrf_token: None, + }) + } else { + let ticket = self + .session_secret("ticket", &self.ticket)? + .ok_or_else(|| Error::AuthError("Not logged in: no session ticket".to_string()))?; + let csrf_token = self.session_secret("csrf_token", &self.csrf_token)?; + Ok(AuthContext { + mode: AuthMode::Password, + token: None, + ticket: Some(ticket), + csrf_token, + }) + } + } + + /// Reads a stored secret for this connection from the keyring, treating + /// missing entries as `None`. + fn keyring_secret(&self, field: &str) -> crate::Result> { + match keyring_entry(&self.config.id, field) { + Ok(entry) => match entry.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(Error::KeyringError(e.to_string())), + }, + Err(_) => Ok(None), + } + } + + /// Returns a session value from memory, loading it from the keyring and + /// caching it on the connection when missing. + fn session_secret( + &self, + field: &str, + cache: &Mutex>, + ) -> crate::Result> { + { + let guard = cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(value) = guard.as_ref() { + return Ok(Some(value.clone())); + } + } + let value = self.keyring_secret(field)?; + if let Some(value) = value.as_ref() { + if let Ok(mut guard) = cache.lock() { + *guard = Some(value.clone()); + } + } + Ok(value) + } + + /// Returns the ordered, deduplicated candidate endpoint URLs for failover: + /// the primary first, followed by each configured fallback. Empty URLs are + /// dropped and duplicates are collapsed, preserving order. + fn endpoint_urls(&self) -> Vec { + let mut urls: Vec = Vec::new(); + let primary = self.config.primary.url.clone(); + let fallbacks = self + .config + .fallbacks + .iter() + .map(|endpoint| endpoint.url.clone()); + for url in std::iter::once(primary).chain(fallbacks) { + if url.is_empty() || urls.contains(&url) { + continue; + } + urls.push(url); + } + urls + } + + /// Remembers the endpoint that last served a request, so the next request + /// resumes rotation there instead of re-testing a down primary. + fn set_endpoint_index(&self, idx: usize) { + if let Ok(mut guard) = self.current_endpoint_index.lock() { + *guard = idx; + } + } + + /// Records the runtime status of the last request: `"connected"`, + /// `"failover"`, or `"failed"`. + fn set_runtime_status(&self, status: &str) { + if let Ok(mut guard) = self.runtime_status.lock() { + *guard = status.to_string(); + } + } + + /// Returns the runtime status of this connection. + fn runtime_status(&self) -> String { + self.runtime_status + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + /// Sends an authenticated request to this connection, rotating across the + /// configured endpoints on transport-level failures. + /// + /// The rotation resumes at the endpoint that last served a request, so a + /// down primary is not re-tested on every call. Transport failures + /// (connection refused, timeouts, DNS resolution) trigger failover to the + /// next candidate; authentication and API errors are returned immediately + /// without rotating. + async fn request( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + form: Option<&[(&str, String)]>, + ) -> crate::Result { + let auth = self.auth_context()?; + let candidates = self.endpoint_urls(); + let start = *self + .current_endpoint_index + .lock() + .unwrap_or_else(|e| e.into_inner()); + let mut last_transport_err = None; + for offset in 0..candidates.len() { + let idx = (start + offset) % candidates.len(); + let url = &candidates[idx]; + match api_request(&self.client, url, method.clone(), path, &auth, query, form).await { + Ok(value) => { + self.set_endpoint_index(idx); + self.set_runtime_status(if idx == 0 { "connected" } else { "failover" }); + return Ok(value); + } + Err(Error::ConnectionFailed(message)) => { + last_transport_err = Some(Error::ConnectionFailed(message)); + } + Err(error) => return Err(error), + } + } + self.set_runtime_status("failed"); + Err(last_transport_err + .unwrap_or_else(|| Error::ConnectionFailed("no endpoints available".to_string()))) + } } fn keyring_service() -> &'static str { @@ -22,41 +375,95 @@ fn keyring_service() -> &'static str { fn keyring_entry(connection_id: &str, field: &str) -> crate::Result { let key = format!("{}:{}", connection_id, field); - keyring::Entry::new(keyring_service(), &key) - .map_err(|e| Error::KeyringError(e.to_string())) + keyring::Entry::new(keyring_service(), &key).map_err(|e| Error::KeyringError(e.to_string())) } pub struct ConnectionManager { connections: HashMap, + active_connection_id: Option, } impl ConnectionManager { pub fn new() -> Self { Self { connections: HashMap::new(), + active_connection_id: None, } } - pub async fn add_connection(&mut self, config: ConnectionConfig) -> crate::Result<()> { + /// 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, + ticket: Mutex::new(None), + csrf_token: Mutex::new(None), + current_endpoint_index: Mutex::new(0), + runtime_status: Mutex::new("connected".to_string()), + }) + } + + pub async fn add_connection( + &mut self, + config: ConnectionConfig, + path: &Path, + ) -> crate::Result<()> { if config.primary.url.is_empty() { return Err(Error::InvalidUrl("URL cannot be empty".to_string())); } let id = config.id.clone(); - let connection = Connection { - config, - client: Client::builder() - .danger_accept_invalid_certs(true) - .build() - .map_err(Error::HttpError)?, - ticket: None, - csrf_token: None, - current_endpoint_index: 0, - }; - self.connections.insert(id, connection); - Ok(()) + self.connections.insert(id, Self::build_connection(config)?); + self.persist(path) } - pub async fn remove_connection(&mut self, id: &str) -> crate::Result<()> { + /// Updates an existing connection and persists it. The id must reference + /// an existing connection; the URL is immutable (changing servers is a + /// remove + re-add) so the client is rebuilt from the incoming config + /// verbatim. + /// + /// Certificate pinning and trust settings are preserved from the existing + /// connection when the incoming config omits them (`cert_fingerprint` is + /// optional; `trusted` and `accept_untrusted` default to `false` when + /// absent, so a missing value keeps the previous one). + pub async fn update_connection( + &mut self, + config: ConnectionConfig, + path: &Path, + ) -> crate::Result<()> { + if config.primary.url.is_empty() { + return Err(Error::InvalidUrl("URL cannot be empty".to_string())); + } + let id = config.id.clone(); + if !self.connections.contains_key(&id) { + return Err(Error::ConnectionNotFound(id)); + } + + let mut updated = config; + let existing = &self + .connections + .get(&id) + .expect("existence was checked above") + .config; + if updated.cert_fingerprint.is_none() { + updated.cert_fingerprint = existing.cert_fingerprint.clone(); + } + if !updated.trusted { + updated.trusted = existing.trusted; + } + if !updated.accept_untrusted { + updated.accept_untrusted = existing.accept_untrusted; + } + + self.connections + .insert(id, Self::build_connection(updated)?); + self.persist(path) + } + + pub async fn remove_connection(&mut self, id: &str, path: &Path) -> crate::Result<()> { self.connections.remove(id); // Clear stored credentials from keyring let _ = keyring_entry(id, "ticket").and_then(|e| { @@ -75,23 +482,420 @@ impl ConnectionManager { e.delete_credential() .map_err(|e| Error::KeyringError(e.to_string())) }); + if self.active_connection_id.as_deref() == Some(id) { + self.active_connection_id = None; + } + self.persist(path) + } + + /// Sets the active connection and persists it. The id must reference an + /// existing connection. + pub async fn set_active_connection(&mut self, id: String, path: &Path) -> crate::Result<()> { + if !self.connections.contains_key(&id) { + return Err(Error::ConnectionNotFound(id)); + } + self.active_connection_id = Some(id); + self.persist(path) + } + + /// Loads persisted connection configs from `path` and rebuilds in-memory + /// connections from them. Every loaded connection starts as disconnected. + /// + /// A missing file (first launch) yields an empty result without an error. + pub async fn load_connections(&mut self, path: &Path) -> crate::Result { + if !path.exists() { + return Ok(LoadResult { + active_connection_id: None, + connections: Vec::new(), + }); + } + + let content = std::fs::read_to_string(path).map_err(|e| { + Error::SerializationError(format!( + "Failed to read connections file '{}': {}", + path.display(), + e + )) + })?; + let state: PersistedState = serde_json::from_str(&content).map_err(|e| { + Error::SerializationError(format!( + "Failed to parse connections file '{}': {}", + path.display(), + e + )) + })?; + + self.active_connection_id = state.active_connection_id; + self.connections.clear(); + for mut config in state.connections { + // A persisted "connected" status would be a lie after a restart. + config.status = "disconnected".to_string(); + let id = config.id.clone(); + self.connections.insert(id, Self::build_connection(config)?); + } + + Ok(LoadResult { + active_connection_id: self.active_connection_id.clone(), + connections: self + .connections + .values() + .map(|conn| conn.config.clone()) + .collect(), + }) + } + + pub async fn connect(&mut self, id: &str, path: &Path) -> crate::Result { + let (url, pinned, accept_untrusted, auth_mode) = { + let conn = self + .connections + .get(id) + .ok_or_else(|| Error::ConnectionNotFound(id.to_string()))?; + ( + conn.config.primary.url.clone(), + conn.config.cert_fingerprint.clone(), + conn.config.accept_untrusted, + conn.config.auth_mode.clone(), + ) + }; + + // Certificate verification (TOFU): compare the served certificate + // against the pinned fingerprint. With no pin and no escape hatch the + // connect fails and the frontend offers the trust flow. When the + // escape hatch is set there is nothing to verify, so connect proceeds. + // A certificate failure short-circuits before any discovery or merge. + match pinned.as_deref() { + Some(pin) => { + crate::tls::verify_server_certificate(&url, Some(pin), accept_untrusted).await?; + } + None if !accept_untrusted => { + return Err(Error::CertificateError( + "Certificate has not been trusted yet. Trust it from the connection dialog." + .to_string(), + )); + } + None => {} + } + + // Authenticate, reset the failover state, and discover the cluster + // nodes. When no endpoint is reachable this surfaces a transport error + // that is reported as a `"failed"` `ConnectResult` instead of an + // error, so the frontend can still track the connection. Other + // failures (bad credentials, API errors) propagate unchanged. + if let Err(error) = self.authenticate_and_discover(id, &url, &auth_mode).await { + return self.failed_or_propagate(id, path, error); + } + + // Same-cluster merge: when this connection belongs to the same cluster + // as an existing one, fold its endpoint and node list into the + // existing connection and drop it. + let cluster_id = { + let conn = self + .connections + .get(id) + .expect("connection existence was checked above"); + conn.config.cluster_id.clone() + }; + + if let Some(cid) = cluster_id.filter(|cid| !cid.is_empty()) { + let other_id = self + .connections + .iter() + .find(|(other_id, conn)| { + other_id.as_str() != id + && conn.config.cluster_id.as_deref() == Some(cid.as_str()) + }) + .map(|(other_id, _)| other_id.clone()); + + if let Some(other_id) = other_id { + let (this_primary_url, this_primary_node, this_nodes) = { + let conn = self + .connections + .get(id) + .expect("connection existence was checked above"); + ( + conn.config.primary.url.clone(), + conn.config.primary.node.clone(), + conn.config.nodes.clone(), + ) + }; + + { + let other = self + .connections + .get_mut(&other_id) + .expect("merge target was located above"); + // This connection's primary endpoint becomes a fallback on + // the surviving connection, deduplicated case-insensitively. + let url_known = other + .config + .fallbacks + .iter() + .any(|endpoint| endpoint.url.eq_ignore_ascii_case(&this_primary_url)); + if !url_known { + other.config.fallbacks.push(EndpointConfig { + url: this_primary_url.clone(), + node: this_primary_node.clone(), + token: None, + }); + } + // Adopt the merging connection's primary node only when the + // surviving connection has none yet. + if other + .config + .primary + .node + .as_deref() + .map_or(true, str::is_empty) + { + other.config.primary.node = this_primary_node; + } + // Merge the node lists (dedup by URL), then re-derive each + // node's primary marker against the surviving connection's + // primary URL. + let other_primary_url = other.config.primary.url.clone(); + for node in this_nodes { + if !other + .config + .nodes + .iter() + .any(|existing| existing.url.eq_ignore_ascii_case(&node.url)) + { + other.config.nodes.push(node); + } + } + for node in &mut other.config.nodes { + node.is_primary = node.url.eq_ignore_ascii_case(&other_primary_url); + } + if other + .config + .primary + .node + .as_deref() + .map_or(true, str::is_empty) + { + if let Some(primary) = + other.config.nodes.iter().find(|node| node.is_primary) + { + other.config.primary.node = Some(primary.name.clone()); + } + } + } + + self.connections.remove(id); + if self.active_connection_id.as_deref() == Some(id) { + self.active_connection_id = Some(other_id.clone()); + } + self.persist(path)?; + return Ok(ConnectResult { + connection_id: other_id.clone(), + merged_into: Some(other_id), + status: "connected".to_string(), + }); + } + } + + let conn = self + .connections + .get_mut(id) + .expect("connection existence was checked above"); + conn.config.status = "connected".to_string(); + self.persist(path)?; + Ok(ConnectResult { + connection_id: id.to_string(), + merged_into: None, + status: "connected".to_string(), + }) + } + + /// Authenticates a connection against the server (token mode validates the + /// token against `/version`; password mode refreshes a fresh session from + /// the stored credentials), resets the failover state so a reconnect starts + /// on the primary endpoint, and discovers the cluster's nodes. A + /// transport-level failure here means no endpoint was reachable. + async fn authenticate_and_discover( + &mut self, + id: &str, + url: &str, + auth_mode: &str, + ) -> crate::Result<()> { + match auth_mode { + "token" => { + let conn = self.connection(id)?; + conn.request(Method::GET, "/version", &[], None).await?; + } + _ => { + let login = self.refresh_ticket(id, url).await?; + let conn = self.connection(id)?; + conn.set_session(login.ticket, login.csrf_token); + } + } + let conn = self.connection(id)?; + conn.set_endpoint_index(0); + conn.set_runtime_status("connected"); + self.discover_nodes(id).await?; Ok(()) } - pub async fn connect(&mut self, id: &str) -> crate::Result<()> { - if let Some(conn) = self.connections.get_mut(id) { - conn.config.status = "connected".to_string(); + /// Maps a post-certificate connect failure into the connect outcome. + /// Transport-level failures (no endpoint reachable) report the connection + /// as `"failed"` so it stays tracked; every other error propagates + /// unchanged. + fn failed_or_propagate( + &mut self, + id: &str, + path: &Path, + error: Error, + ) -> crate::Result { + match error { + Error::ConnectionFailed(_) => { + if let Some(conn) = self.connections.get_mut(id) { + conn.config.status = "failed".to_string(); + conn.set_endpoint_index(0); + conn.set_runtime_status("failed"); + } + self.persist(path)?; + Ok(ConnectResult { + connection_id: id.to_string(), + merged_into: None, + status: "failed".to_string(), + }) + } + other => Err(other), } - Ok(()) } pub async fn disconnect(&mut self, id: &str) -> crate::Result<()> { if let Some(conn) = self.connections.get_mut(id) { + if let Ok(mut guard) = conn.ticket.lock() { + *guard = None; + } + if let Ok(mut guard) = conn.csrf_token.lock() { + *guard = None; + } conn.config.status = "disconnected".to_string(); + conn.set_endpoint_index(0); + conn.set_runtime_status("disconnected"); } Ok(()) } + /// Returns a snapshot of a connection's runtime state for the status bar: + /// the effective status, the primary and currently-serving endpoints, and + /// the last discovered node list. + /// + /// A disconnected connection reports `"disconnected"` without touching the + /// network. Otherwise the node list is refreshed best-effort: a transport + /// failure (all endpoints unreachable) keeps the stored nodes and reports + /// `"failed"`; any other failure keeps the stored nodes and the current + /// runtime status. This never persists, so polling it does not write the + /// config file. + pub async fn status_info( + &mut self, + connection_id: &str, + ) -> crate::Result { + if self.connection(connection_id)?.config.status == "disconnected" { + let (primary_url, current_endpoint_url, nodes) = self.status_snapshot(connection_id)?; + return Ok(ConnectionStatusInfo { + connection_id: connection_id.to_string(), + status: "disconnected".to_string(), + primary_url, + current_endpoint_url, + nodes, + }); + } + + let transport_failed = match self.discover_nodes(connection_id).await { + Ok(_) => false, + Err(Error::ConnectionFailed(_)) => true, + Err(_) => false, + }; + + let (primary_url, current_endpoint_url, nodes) = self.status_snapshot(connection_id)?; + let runtime_status = self.runtime_status(connection_id)?; + Ok(ConnectionStatusInfo { + connection_id: connection_id.to_string(), + status: if transport_failed { + "failed".to_string() + } else { + runtime_status + }, + primary_url, + current_endpoint_url, + nodes, + }) + } + + /// Reads the currently-serving endpoint URLs and the stored node list + /// without performing any network I/O. Falls back to the primary URL when + /// the endpoint list is empty. + fn status_snapshot( + &self, + connection_id: &str, + ) -> crate::Result<(String, String, Vec)> { + let conn = self.connection(connection_id)?; + let urls = conn.endpoint_urls(); + let index = *conn + .current_endpoint_index + .lock() + .unwrap_or_else(|e| e.into_inner()); + Ok(( + conn.config.primary.url.clone(), + urls.get(index) + .cloned() + .unwrap_or_else(|| conn.config.primary.url.clone()), + conn.config.nodes.clone(), + )) + } + + /// Serializes the current connections and active id to `path`. + /// + /// Secrets (API tokens, on both the primary and fallback endpoints) are + /// stripped before writing so they never touch disk; after a restart the + /// request layer falls back to the OS keyring. + fn persist(&self, path: &Path) -> crate::Result<()> { + let connections: Vec = self + .connections + .values() + .map(|conn| { + let mut config = conn.config.clone(); + config.primary.token = None; + for endpoint in &mut config.fallbacks { + endpoint.token = None; + } + config + }) + .collect(); + + let state = PersistedState { + active_connection_id: self.active_connection_id.clone(), + connections, + }; + let json = serde_json::to_string_pretty(&state).map_err(|e| { + Error::SerializationError(format!("Failed to serialize connections: {}", e)) + })?; + + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| { + Error::SerializationError(format!( + "Failed to create directory '{}': {}", + parent.display(), + e + )) + })?; + } + } + + std::fs::write(path, json).map_err(|e| { + Error::SerializationError(format!( + "Failed to write connections file '{}': {}", + path.display(), + e + )) + })?; + Ok(()) + } + pub async fn login_with_password( &self, url: &str, @@ -109,10 +913,7 @@ impl ConnectionManager { let login_url = format!("{}/access/ticket", url); - let params = [ - ("username", username), - ("password", password), - ]; + let params = [("username", username), ("password", password)]; let response = client .post(&login_url) @@ -150,7 +951,7 @@ impl ConnectionManager { // Generate a stable connection ID from the URL let connection_id = { - use sha2::{Sha256, Digest}; + use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(url.as_bytes()); let hash = hasher.finalize(); @@ -158,14 +959,28 @@ impl ConnectionManager { }; // Store credentials in keyring for later use - keyring_entry(&connection_id, "ticket") - .and_then(|e| e.set_password(&ticket).map_err(|e| Error::KeyringError(e.to_string())))?; - keyring_entry(&connection_id, "csrf_token") - .and_then(|e| e.set_password(&csrf_token).map_err(|e| Error::KeyringError(e.to_string())))?; - keyring_entry(&connection_id, "username") - .and_then(|e| e.set_password(username).map_err(|e| Error::KeyringError(e.to_string())))?; - keyring_entry(&connection_id, "password") - .and_then(|e| e.set_password(password).map_err(|e| Error::KeyringError(e.to_string())))?; + keyring_entry(&connection_id, "ticket").and_then(|e| { + e.set_password(&ticket) + .map_err(|e| Error::KeyringError(e.to_string())) + })?; + keyring_entry(&connection_id, "csrf_token").and_then(|e| { + e.set_password(&csrf_token) + .map_err(|e| Error::KeyringError(e.to_string())) + })?; + keyring_entry(&connection_id, "username").and_then(|e| { + e.set_password(username) + .map_err(|e| Error::KeyringError(e.to_string())) + })?; + keyring_entry(&connection_id, "password").and_then(|e| { + e.set_password(password) + .map_err(|e| Error::KeyringError(e.to_string())) + })?; + + // Keep the in-memory session of an already-added connection in sync so + // requests made right after login have auth available. + if let Some(conn) = self.connections.get(&connection_id) { + conn.set_session(ticket.clone(), csrf_token.clone()); + } Ok(LoginResult { connection_id, @@ -174,16 +989,14 @@ impl ConnectionManager { }) } - pub async fn login_with_token( - &self, - url: &str, - token: &str, - ) -> crate::Result { + pub async fn login_with_token(&self, url: &str, token: &str) -> crate::Result { if url.is_empty() { return Err(Error::InvalidUrl("URL cannot be empty".to_string())); } if token.is_empty() { - return Err(Error::InvalidCredentials("API token cannot be empty".to_string())); + return Err(Error::InvalidCredentials( + "API token cannot be empty".to_string(), + )); } let client = Client::builder() @@ -214,7 +1027,7 @@ impl ConnectionManager { // Generate a stable connection ID from the URL let connection_id = { - use sha2::{Sha256, Digest}; + use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(url.as_bytes()); let hash = hasher.finalize(); @@ -222,8 +1035,10 @@ impl ConnectionManager { }; // Store token in keyring for later use - keyring_entry(&connection_id, "token") - .and_then(|e| e.set_password(token).map_err(|e| Error::KeyringError(e.to_string())))?; + keyring_entry(&connection_id, "token").and_then(|e| { + e.set_password(token) + .map_err(|e| Error::KeyringError(e.to_string())) + })?; Ok(LoginResult { connection_id, @@ -256,7 +1071,10 @@ impl ConnectionManager { Ok(()) } - pub async fn get_stored_credentials(&self, connection_id: &str) -> crate::Result> { + pub async fn get_stored_credentials( + &self, + connection_id: &str, + ) -> crate::Result> { let entry = match keyring_entry(connection_id, "ticket") { Ok(e) => e, Err(_) => return Ok(None), @@ -276,17 +1094,25 @@ impl ConnectionManager { password: Option<&str>, api_token: Option<&str>, ) -> crate::Result<()> { - keyring_entry(connection_id, "ticket") - .and_then(|e| e.set_password(ticket).map_err(|e| Error::KeyringError(e.to_string())))?; - keyring_entry(connection_id, "csrf_token") - .and_then(|e| e.set_password(csrf_token).map_err(|e| Error::KeyringError(e.to_string())))?; + keyring_entry(connection_id, "ticket").and_then(|e| { + e.set_password(ticket) + .map_err(|e| Error::KeyringError(e.to_string())) + })?; + keyring_entry(connection_id, "csrf_token").and_then(|e| { + e.set_password(csrf_token) + .map_err(|e| Error::KeyringError(e.to_string())) + })?; if let Some(pw) = password { - keyring_entry(connection_id, "password") - .and_then(|e| e.set_password(pw).map_err(|e| Error::KeyringError(e.to_string())))?; + keyring_entry(connection_id, "password").and_then(|e| { + e.set_password(pw) + .map_err(|e| Error::KeyringError(e.to_string())) + })?; } if let Some(tok) = api_token { - keyring_entry(connection_id, "token") - .and_then(|e| e.set_password(tok).map_err(|e| Error::KeyringError(e.to_string())))?; + keyring_entry(connection_id, "token").and_then(|e| { + e.set_password(tok) + .map_err(|e| Error::KeyringError(e.to_string())) + })?; } Ok(()) } @@ -296,108 +1122,435 @@ impl ConnectionManager { connection_id: &str, url: &str, ) -> crate::Result { - // Try to get stored password for re-authentication - let password = keyring_entry(connection_id, "password") - .and_then(|e| e.get_password().map_err(|e| Error::KeyringError(e.to_string())))?; - - // Get stored username for re-authentication - let username = keyring_entry(connection_id, "username") - .and_then(|e| e.get_password().map_err(|e| Error::KeyringError(e.to_string()))) - .unwrap_or_default(); + let password = Self::stored_credential(connection_id, "password")?; + let username = Self::stored_credential(connection_id, "username")?; self.login_with_password(url, &username, &password).await } - pub async fn get_certificate_info(&self, url: &str) -> crate::Result { - Ok(CertificateInfo { - fingerprint: "AB:CD:EF:12:34:56:78:90".to_string(), - issuer: "Proxmox".to_string(), - subject: "pve".to_string(), - valid_from: "2024-01-01".to_string(), - valid_to: "2034-01-01".to_string(), - self_signed: true, - }) + /// Reads a stored credential from the keyring, mapping missing entries to a + /// clean [`Error::AuthError`] instead of leaking a keyring error. + fn stored_credential(connection_id: &str, field: &str) -> crate::Result { + match keyring_entry(connection_id, field) { + Ok(entry) => match entry.get_password() { + Ok(value) => Ok(value), + Err(keyring::Error::NoEntry) => Err(Error::AuthError( + "No stored credentials for re-authentication".to_string(), + )), + Err(e) => Err(Error::KeyringError(e.to_string())), + }, + Err(_) => Err(Error::AuthError( + "No stored credentials for re-authentication".to_string(), + )), + } } - pub async fn trust_certificate(&self, id: &str, fingerprint: &str) -> crate::Result<()> { + pub async fn get_certificate_info(&self, url: &str) -> crate::Result { + crate::tls::fetch_certificate_info(url).await + } + + pub async fn trust_certificate( + &mut self, + id: &str, + fingerprint: &str, + path: &Path, + ) -> crate::Result<()> { + let connection = self + .connections + .get_mut(id) + .ok_or_else(|| Error::ConnectionNotFound(id.to_string()))?; + connection.config.cert_fingerprint = Some(fingerprint.to_string()); + connection.config.trusted = true; + self.persist(path) + } + + /// Looks up a connection by id, mapping a missing id to + /// [`Error::ConnectionNotFound`]. + fn connection(&self, id: &str) -> crate::Result<&Connection> { + self.connections + .get(id) + .ok_or_else(|| Error::ConnectionNotFound(id.to_string())) + } + + /// Returns the runtime status of a connection: `"connected"` when the most + /// recent request was served by the primary endpoint, `"failover"` when a + /// fallback served it, or `"failed"` when no endpoint was reachable. This + /// is independent of the persisted `config.status`. + pub fn runtime_status(&self, connection_id: &str) -> crate::Result { + let conn = self.connection(connection_id)?; + Ok(conn.runtime_status()) + } + + /// Returns a clone of a connection's config, including the last discovered + /// node list and cluster identity. + pub fn connection_config(&self, connection_id: &str) -> crate::Result { + let conn = self.connection(connection_id)?; + Ok(conn.config.clone()) + } + + /// Validates that `vm_type` is one of the Proxmox VM type path segments + /// (`qemu` for VMs, `lxc` for containers). + fn validate_vm_type(vm_type: &str) -> crate::Result<()> { + match vm_type { + "qemu" | "lxc" => Ok(()), + other => Err(Error::InvalidUrl(format!( + "Invalid VM type '{}': expected 'qemu' or 'lxc'", + other + ))), + } + } + + /// Returns the name of the first online node reported by `/nodes`. `purpose` + /// names the operation in the error when the cluster has no online node. + async fn first_online_node(&self, connection_id: &str, purpose: &str) -> crate::Result { + let nodes = self.get_nodes(connection_id).await?; + nodes + .iter() + .find(|node| node.status == "online") + .map(|node| node.node.clone()) + .ok_or_else(|| { + Error::ApiError(format!( + "Cannot determine a node for {}: no online node available", + purpose + )) + }) + } + + /// Resolves the node for a node-scoped storage/backup endpoint: the node + /// pinned on the connection's primary endpoint, falling back to the first + /// online node in the cluster. + async fn storage_node(&self, connection_id: &str) -> crate::Result { + let conn = self.connection(connection_id)?; + match conn + .config + .primary + .node + .as_deref() + .filter(|n| !n.is_empty()) + { + Some(node) => Ok(node.to_string()), + None => { + self.first_online_node(connection_id, "storage operations") + .await + } + } + } + + /// Sends a VM/container lifecycle action (`start`, `stop`, `shutdown`, + /// `reboot`, `suspend`, `resume`) to the given guest. + async fn vm_status_action( + &self, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + action: &str, + ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/status/{}", node, vm_type, vmid, action); + conn.request(Method::POST, &path, &[], None).await?; Ok(()) } pub async fn get_nodes(&self, connection_id: &str) -> crate::Result> { - Ok(vec![]) + let conn = self.connection(connection_id)?; + let data = conn.request(Method::GET, "/nodes", &[], None).await?; + parse_api("/nodes", data) } pub async fn get_vms(&self, connection_id: &str) -> crate::Result> { - Ok(vec![]) + let conn = self.connection(connection_id)?; + let query = [("type", "vm".to_string())]; + let data = conn + .request(Method::GET, "/cluster/resources", &query, None) + .await?; + parse_api("/cluster/resources?type=vm", data) } pub async fn get_storage(&self, connection_id: &str) -> crate::Result> { - Ok(vec![]) + let conn = self.connection(connection_id)?; + let query = [("type", "storage".to_string())]; + let data = conn + .request(Method::GET, "/cluster/resources", &query, None) + .await?; + parse_api("/cluster/resources?type=storage", data) } pub async fn get_storage_content( &self, - _connection_id: &str, - _storage: &str, + connection_id: &str, + storage: &str, ) -> crate::Result> { - Ok(vec![]) + 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?; + let path = format!("/nodes/{}/storage/{}/content", node, storage); + let data = conn.request(Method::GET, &path, &[], None).await?; + parse_api(&path, data) } pub async fn get_storage_detail( &self, - _connection_id: &str, - _node: &str, - _storage: &str, + connection_id: &str, + node: &str, + storage: &str, ) -> crate::Result { - Ok(StorageDetail { - storage: String::new(), - r#type: String::new(), - content: String::new(), - active: 0, - enabled: 0, - shared: 0, - used: 0, - total: 0, - avail: 0, - node: String::new(), - }) + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/storage/{}/status", node, storage); + let data = conn.request(Method::GET, &path, &[], None).await?; + parse_api(&path, data) } pub async fn get_tasks(&self, connection_id: &str) -> crate::Result> { - Ok(vec![]) + let conn = self.connection(connection_id)?; + let query = [("limit", "50".to_string())]; + let data = conn + .request(Method::GET, "/cluster/tasks", &query, None) + .await?; + parse_api("/cluster/tasks", data) } pub async fn get_cluster_status(&self, connection_id: &str) -> crate::Result { + let conn = self.connection(connection_id)?; + let data = conn + .request(Method::GET, "/cluster/status", &[], None) + .await?; + let entries = match data { + serde_json::Value::Array(entries) => entries, + _ => { + return Err(Error::SerializationError( + "Failed to parse /cluster/status response: expected an array".to_string(), + )) + } + }; + + // The response mixes one `cluster` entry with several `node` entries. + let mut nodes = Vec::new(); + let mut found_cluster = false; + let mut cluster_name = None; + let mut cluster_id = None; + for entry in &entries { + match entry["type"].as_str() { + Some("cluster") => { + found_cluster = true; + cluster_name = entry["name"].as_str().map(str::to_string); + cluster_id = entry["id"].as_str().map(str::to_string); + } + Some("node") => { + let name = entry["node"] + .as_str() + .map(str::to_string) + .or_else(|| { + entry["id"] + .as_str() + .and_then(|id| id.strip_prefix("node/")) + .map(str::to_string) + }) + .unwrap_or_default(); + nodes.push(ClusterNode { + name, + nodeid: entry["nodeid"].as_u64().unwrap_or(0) as u32, + online: entry["online"] + .as_u64() + .map(|v| v as u32) + .or_else(|| entry["online"].as_bool().map(u32::from)) + .unwrap_or(0), + local: entry["local"] + .as_u64() + .map(|v| v as u32) + .or_else(|| entry["local"].as_bool().map(u32::from)), + ip: entry["ip"].as_str().map(str::to_string), + }); + } + _ => {} + } + } + + if !found_cluster { + cluster_name = Some("default".to_string()); + cluster_id = Some(String::new()); + } + Ok(ClusterStatus { r#type: "cluster".to_string(), - name: "default".to_string(), - id: "cluster/default".to_string(), - nodes: None, + name: cluster_name.unwrap_or_default(), + id: cluster_id.unwrap_or_default(), + nodes: Some(nodes), }) } - pub async fn start_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> { - Ok(()) + /// Discovers the cluster nodes reachable through this connection and + /// stores them on the connection config. + /// + /// The node list and their online/offline status come from `/nodes`; the + /// cluster membership map (IP addresses and the local flag) comes from + /// `/cluster/status`. Each discovered node's URL is derived from the + /// primary endpoint URL with the node's cluster IP (or name) substituted + /// into the host position. The node matching the primary endpoint — by + /// derived URL or by the configured `primary.node` name — is marked as + /// primary and sorted first. + /// + /// The discovered list, cluster name and cluster id are stored on the + /// config (persisted on the next `persist`). When `primary.node` is unset + /// it is filled in from the primary (or first online) node so node-scoped + /// endpoints have a node to target. + pub async fn discover_nodes( + &mut self, + connection_id: &str, + ) -> crate::Result> { + let (primary_url, configured_node) = { + let conn = self + .connections + .get(connection_id) + .ok_or_else(|| Error::ConnectionNotFound(connection_id.to_string()))?; + ( + conn.config.primary.url.clone(), + conn.config.primary.node.clone(), + ) + }; + + let nodes = self.get_nodes(connection_id).await?; + let cluster = self.get_cluster_status(connection_id).await?; + + // name -> (ip, local) from the cluster status node entries. + let mut cluster_nodes: HashMap, bool)> = HashMap::new(); + if let Some(entries) = &cluster.nodes { + for entry in entries { + cluster_nodes.insert( + entry.name.clone(), + (entry.ip.clone(), entry.local.unwrap_or(0) != 0), + ); + } + } + + let mut discovered: Vec = nodes + .iter() + .map(|node| { + let (ip, local) = cluster_nodes + .get(&node.node) + .cloned() + .unwrap_or_else(|| (None, false)); + let url = derive_node_url(&primary_url, ip.as_deref(), &node.node); + let is_primary = url.eq_ignore_ascii_case(&primary_url) + || configured_node.as_deref() == Some(node.node.as_str()); + DiscoveredNode { + name: node.node.clone(), + url, + status: node.status.clone(), + is_primary, + local, + } + }) + .collect(); + + // The primary node first, then the rest alphabetically. + discovered.sort_by(|a, b| { + b.is_primary + .cmp(&a.is_primary) + .then_with(|| a.name.cmp(&b.name)) + }); + + let conn = self + .connections + .get_mut(connection_id) + .expect("connection existence was checked above"); + conn.config.nodes = discovered.clone(); + + // Only a real cluster entry (non-empty id) sets the cluster identity; + // a standalone server leaves the config's cluster fields untouched. + if !cluster.id.is_empty() { + if conn.config.cluster_name.is_none() && !cluster.name.is_empty() { + conn.config.cluster_name = Some(cluster.name.clone()); + } + conn.config.cluster_id = Some(cluster.id.clone()); + } + + if conn + .config + .primary + .node + .as_deref() + .map_or(true, str::is_empty) + { + if let Some(primary) = discovered + .iter() + .find(|node| node.is_primary) + .or_else(|| discovered.iter().find(|node| node.status == "online")) + { + conn.config.primary.node = Some(primary.name.clone()); + } + } + + Ok(discovered) } - pub async fn stop_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> { - Ok(()) + pub async fn start_vm( + &self, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + ) -> crate::Result<()> { + self.vm_status_action(connection_id, node, vmid, vm_type, "start") + .await } - pub async fn shutdown_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> { - Ok(()) + pub async fn stop_vm( + &self, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + ) -> crate::Result<()> { + self.vm_status_action(connection_id, node, vmid, vm_type, "stop") + .await } - pub async fn reboot_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> { - Ok(()) + pub async fn shutdown_vm( + &self, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + ) -> crate::Result<()> { + self.vm_status_action(connection_id, node, vmid, vm_type, "shutdown") + .await } - pub async fn suspend_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> { - Ok(()) + pub async fn reboot_vm( + &self, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + ) -> crate::Result<()> { + self.vm_status_action(connection_id, node, vmid, vm_type, "reboot") + .await } - pub async fn resume_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> { - Ok(()) + pub async fn suspend_vm( + &self, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + ) -> crate::Result<()> { + self.vm_status_action(connection_id, node, vmid, vm_type, "suspend") + .await + } + + pub async fn resume_vm( + &self, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + ) -> crate::Result<()> { + self.vm_status_action(connection_id, node, vmid, vm_type, "resume") + .await } pub async fn get_disks( @@ -405,8 +1558,35 @@ impl ConnectionManager { connection_id: &str, node: &str, vmid: u32, + vm_type: &str, ) -> crate::Result> { - Ok(vec![]) + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid); + let data = conn.request(Method::GET, &path, &[], None).await?; + let obj = data.as_object().ok_or_else(|| { + Error::SerializationError(format!( + "Failed to parse {} response from Proxmox: expected an object", + path + )) + })?; + let mut disks = Vec::new(); + for (key, value) in obj { + if !is_disk_key(key) { + continue; + } + let (storage, size, format) = parse_disk_attrs(value.as_str().unwrap_or_default()); + disks.push(Disk { + device: key.clone(), + size, + storage, + format, + // There is no reliable API source for per-disk usage, so it + // stays None. + usage: None, + }); + } + Ok(disks) } pub async fn add_disk( @@ -414,8 +1594,26 @@ impl ConnectionManager { connection_id: &str, node: &str, vmid: u32, - _config: AddDiskConfig, + vm_type: &str, + config: AddDiskConfig, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + const VALID_BUSES: [&str; 4] = ["scsi", "virtio", "ide", "sata"]; + if !VALID_BUSES.contains(&config.bus_type.as_str()) { + return Err(Error::InvalidUrl(format!( + "Invalid bus type '{}': expected one of scsi, virtio, ide, sata", + config.bus_type + ))); + } + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid); + // The current config determines the first free slot on the target bus. + let data = conn.request(Method::GET, &path, &[], None).await?; + let index = first_free_bus_index(&data, &config.bus_type); + let key = format!("{}{}", config.bus_type, index); + let value = format!("{}:{}", config.storage, human_size(config.size)); + let form = vec![(key.as_str(), value)]; + conn.request(Method::POST, &path, &[], Some(&form)).await?; Ok(()) } @@ -424,9 +1622,15 @@ impl ConnectionManager { connection_id: &str, node: &str, vmid: u32, - _disk: &str, - _size: u64, + vm_type: &str, + disk: &str, + size: u64, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/resize", node, vm_type, vmid); + let form = [("disk", disk.to_string()), ("size", human_size(size))]; + conn.request(Method::PUT, &path, &[], Some(&form)).await?; Ok(()) } @@ -435,8 +1639,15 @@ impl ConnectionManager { connection_id: &str, node: &str, vmid: u32, - _disk: &str, + vm_type: &str, + disk: &str, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid); + let form = [("delete", disk.to_string())]; + conn.request(Method::DELETE, &path, &[], Some(&form)) + .await?; Ok(()) } @@ -445,9 +1656,15 @@ impl ConnectionManager { connection_id: &str, node: &str, vmid: u32, - _disk: &str, - _storage: &str, + vm_type: &str, + disk: &str, + storage: &str, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/move_disk", node, vm_type, vmid); + let form = [("disk", disk.to_string()), ("storage", storage.to_string())]; + conn.request(Method::POST, &path, &[], Some(&form)).await?; Ok(()) } @@ -456,8 +1673,35 @@ impl ConnectionManager { connection_id: &str, node: &str, vmid: u32, + vm_type: &str, ) -> crate::Result> { - Ok(vec![]) + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid); + let data = conn.request(Method::GET, &path, &[], None).await?; + let obj = data.as_object().ok_or_else(|| { + Error::SerializationError(format!( + "Failed to parse {} response from Proxmox: expected an object", + path + )) + })?; + let mut nics = Vec::new(); + for (key, value) in obj { + if !is_net_key(key) { + continue; + } + let parsed = parse_net_value(value.as_str().unwrap_or_default()); + nics.push(NetworkInterface { + name: key.clone(), + model: parsed.model, + macaddr: parsed.macaddr, + bridge: parsed.bridge, + tag: parsed.tag, + firewall: parsed.firewall, + link_down: parsed.link_down, + }); + } + Ok(nics) } pub async fn add_nic( @@ -465,8 +1709,34 @@ impl ConnectionManager { connection_id: &str, node: &str, vmid: u32, - _config: AddNICConfig, + vm_type: &str, + config: AddNICConfig, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + const VALID_MODELS: [&str; 4] = ["virtio", "e1000", "rtl8139", "vmxnet3"]; + if !VALID_MODELS.contains(&config.model.as_str()) { + return Err(Error::InvalidUrl(format!( + "Invalid NIC model '{}': expected one of virtio, e1000, rtl8139, vmxnet3", + config.model + ))); + } + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid); + // The current config determines the first free net index. + 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 key = format!("net{}", index); + let form = vec![(key.as_str(), value)]; + conn.request(Method::POST, &path, &[], Some(&form)).await?; Ok(()) } @@ -475,9 +1745,22 @@ impl ConnectionManager { connection_id: &str, node: &str, vmid: u32, - _nic: &str, - _config: EditNICConfig, + vm_type: &str, + nic: &str, + config: EditNICConfig, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid); + // Re-encoding needs the current value of the NIC being edited. + let data = conn.request(Method::GET, &path, &[], None).await?; + let current = data + .get(nic) + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::ApiError(format!("NIC '{}' not found in VM config", nic)))?; + let value = reencode_nic_value(current, &config); + let form = vec![(nic, value)]; + conn.request(Method::POST, &path, &[], Some(&form)).await?; Ok(()) } @@ -486,156 +1769,529 @@ impl ConnectionManager { connection_id: &str, node: &str, vmid: u32, - _nic: &str, + vm_type: &str, + nic: &str, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid); + let form = [("delete", nic.to_string())]; + conn.request(Method::DELETE, &path, &[], Some(&form)) + .await?; Ok(()) } pub async fn get_snapshots( &self, - _connection_id: &str, - _node: &str, - _vmid: u32, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, ) -> crate::Result> { - Ok(vec![]) + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/snapshot", node, vm_type, vmid); + let data = conn.request(Method::GET, &path, &[], None).await?; + parse_api(&path, data) } pub async fn create_snapshot( &self, - _connection_id: &str, - _node: &str, - _vmid: u32, - _config: CreateSnapshotConfig, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + config: CreateSnapshotConfig, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/snapshot", node, vm_type, vmid); + let mut form: Vec<(&str, String)> = vec![("snapname", config.name)]; + if let Some(description) = config.description { + form.push(("description", description)); + } + if config.vmstate.unwrap_or(false) { + form.push(("vmstate", "1".to_string())); + } + conn.request(Method::POST, &path, &[], Some(&form)).await?; Ok(()) } pub async fn delete_snapshot( &self, - _connection_id: &str, - _node: &str, - _vmid: u32, - _name: &str, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + name: &str, ) -> 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); + conn.request(Method::DELETE, &path, &[], None).await?; Ok(()) } pub async fn rollback_snapshot( &self, - _connection_id: &str, - _node: &str, - _vmid: u32, - _name: &str, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + name: &str, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!( + "/nodes/{}/{}/{}/snapshot/{}/rollback", + node, vm_type, vmid, name + ); + conn.request(Method::POST, &path, &[], None).await?; Ok(()) } pub async fn migrate_vm( &self, - _connection_id: &str, - _node: &str, - _vmid: u32, - _target_node: &str, - _online: bool, + connection_id: &str, + node: &str, + vmid: u32, + vm_type: &str, + target_node: &str, + online: bool, ) -> crate::Result<()> { + Self::validate_vm_type(vm_type)?; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/{}/{}/migrate", node, vm_type, vmid); + let form = [ + ("target", target_node.to_string()), + ("online", if online { "1" } else { "0" }.to_string()), + ]; + conn.request(Method::POST, &path, &[], Some(&form)).await?; Ok(()) } + /// Creates a VNC console proxy for a QEMU guest, returning the ticket, + /// websocket port, and server certificate (base64-encoded DER) reported by + /// Proxmox. pub async fn create_vnc_proxy( &self, - _connection_id: &str, - _node: &str, - _vmid: u32, + connection_id: &str, + node: &str, + vmid: u32, ) -> crate::Result { - Ok(VNCProxyResponse { - ticket: String::new(), - port: 0, - cert: String::new(), - }) + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/qemu/{}/vncproxy", node, vmid); + let data = conn.request(Method::POST, &path, &[], None).await?; + parse_api(&path, data) } + /// Creates a terminal proxy for an LXC container, returning the ticket and + /// websocket port reported by Proxmox. pub async fn create_term_proxy( &self, - _connection_id: &str, - _node: &str, - _vmid: u32, + connection_id: &str, + node: &str, + vmid: u32, ) -> crate::Result { - Ok(TermProxyResponse { - ticket: String::new(), - port: 0, - }) + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/lxc/{}/termproxy", node, vmid); + let data = conn.request(Method::POST, &path, &[], None).await?; + parse_api(&path, data) } + /// Returns the base WebSocket origin for the connection's primary + /// endpoint. The frontend builds console websocket URLs as + /// `{origin}/api2/json/nodes/{node}/...`, so this converts the stored + /// `https://host[:port]` (or `http://host[:port]`) into `wss://host[:port]` + /// (or `ws://host[:port]`), preserving the host and any explicit port. A + /// URL without an explicit port keeps none — the default port for the + /// websocket scheme applies. + /// + /// `node` is retained in the signature because callers target a specific + /// node's console path, but the base origin does not depend on it. pub async fn get_websocket_url( &self, - _connection_id: &str, + connection_id: &str, _node: &str, ) -> crate::Result { - Ok(String::new()) + let conn = self.connection(connection_id)?; + let url = Url::parse(&conn.config.primary.url).map_err(|e| { + Error::InvalidUrl(format!( + "Invalid primary URL '{}': {}", + conn.config.primary.url, e + )) + })?; + let scheme = match url.scheme() { + "https" => "wss", + "http" => "ws", + other => { + return Err(Error::InvalidUrl(format!( + "Unsupported URL scheme '{}': expected http or https", + other + ))) + } + }; + let host = url + .host_str() + .filter(|host| !host.is_empty()) + .ok_or_else(|| { + Error::InvalidUrl(format!( + "Primary URL '{}' has no host", + conn.config.primary.url + )) + })?; + let origin = match url.port() { + Some(port) => format!("{}://{}:{}", scheme, host, port), + None => format!("{}://{}", scheme, host), + }; + Ok(origin) } - pub async fn get_backup_jobs( - &self, - _connection_id: &str, - ) -> crate::Result> { - Ok(vec![]) + pub async fn get_backup_jobs(&self, connection_id: &str) -> crate::Result> { + let conn = self.connection(connection_id)?; + let data = conn + .request(Method::GET, "/cluster/backup", &[], None) + .await?; + parse_api("/cluster/backup", data) } pub async fn get_backups( &self, - _connection_id: &str, - _storage: Option<&str>, + connection_id: &str, + storage: Option<&str>, ) -> crate::Result> { - Ok(vec![]) + let conn = self.connection(connection_id)?; + let node = self.storage_node(connection_id).await?; + let storage = storage.unwrap_or("local"); + 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 { + if entry["content"].as_str() != Some("backup") { + continue; + } + let backup: Backup = serde_json::from_value(entry).map_err(|e| { + Error::SerializationError(format!( + "Failed to parse {} response from Proxmox: {}", + path, e + )) + })?; + backups.push(backup); + } + Ok(backups) } pub async fn create_backup_job( &self, - _connection_id: &str, - _config: BackupJobConfig, + connection_id: &str, + config: BackupJobConfig, ) -> crate::Result<()> { + let conn = self.connection(connection_id)?; + let form = backup_job_form(&config); + conn.request(Method::POST, "/cluster/backup", &[], Some(&form)) + .await?; Ok(()) } pub async fn update_backup_job( &self, - _connection_id: &str, - _id: &str, - _config: BackupJobConfig, + connection_id: &str, + id: &str, + config: BackupJobConfig, ) -> crate::Result<()> { + let conn = self.connection(connection_id)?; + let path = format!("/cluster/backup/{}", id); + let form = backup_job_form(&config); + conn.request(Method::PUT, &path, &[], Some(&form)).await?; Ok(()) } - pub async fn delete_backup_job( - &self, - _connection_id: &str, - _id: &str, - ) -> crate::Result<()> { + pub async fn delete_backup_job(&self, connection_id: &str, id: &str) -> crate::Result<()> { + let conn = self.connection(connection_id)?; + let path = format!("/cluster/backup/{}", id); + conn.request(Method::DELETE, &path, &[], None).await?; Ok(()) } pub async fn run_backup( &self, - _connection_id: &str, - _config: BackupJobConfig, + connection_id: &str, + config: BackupJobConfig, ) -> crate::Result<()> { + // Vzdump is node-scoped: prefer the node on the job config, otherwise + // fall back to the first online node in the cluster. + let node = match config.node.as_deref().filter(|n| !n.is_empty()) { + Some(node) => node.to_string(), + None => self.first_online_node(connection_id, "backup").await?, + }; + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/vzdump", node); + let mut form: Vec<(&str, String)> = vec![ + ("mode", config.mode), + ("compress", config.compression), + ("storage", config.storage), + ("all", if config.all { "1" } else { "0" }.to_string()), + ]; + if let Some(vmid) = config.vmid { + form.push(("vmid", vmid)); + } + conn.request(Method::POST, &path, &[], Some(&form)).await?; Ok(()) } pub async fn restore_backup( &self, - _connection_id: &str, - _volid: &str, - _config: RestoreConfig, + connection_id: &str, + volid: &str, + config: RestoreConfig, ) -> crate::Result<()> { + let conn = self.connection(connection_id)?; + let path = format!("/nodes/{}/vzdump", config.node); + let mut form: Vec<(&str, String)> = vec![ + ("restore", "1".to_string()), + ("volid", volid.to_string()), + ("storage", config.storage), + ("node", config.node), + ]; + if let Some(vmid) = config.vmid { + form.push(("vmid", vmid.to_string())); + } + conn.request(Method::POST, &path, &[], Some(&form)).await?; Ok(()) } - pub async fn delete_backup( - &self, - _connection_id: &str, - _volid: &str, - ) -> crate::Result<()> { + pub async fn delete_backup(&self, connection_id: &str, volid: &str) -> crate::Result<()> { + let conn = self.connection(connection_id)?; + let node = self.storage_node(connection_id).await?; + // The storage is the volid prefix before the first `:`. + let storage = volid.split(':').next().unwrap_or("").to_string(); + // Volume ids contain `:` and `/` that must be percent-encoded in the + // path; Proxmox only accepts the encoded form. + let encoded_volid = utf8_percent_encode(volid, VOLID_ENCODE_SET).to_string(); + let path = format!( + "/nodes/{}/storage/{}/content/{}", + node, storage, encoded_volid + ); + conn.request(Method::DELETE, &path, &[], None).await?; Ok(()) } } + +/// Maps a [`BackupJobConfig`] to the form fields accepted by the +/// `/cluster/backup` create and update endpoints. The optional `node` and +/// `vmid` fields are included only when set; `vmid` is a comma-joined list of +/// VM ids (e.g. `"100,101"`). +fn backup_job_form(config: &BackupJobConfig) -> Vec<(&'static str, String)> { + let mut form: Vec<(&'static str, String)> = vec![ + ("schedule", config.schedule.clone()), + ("storage", config.storage.clone()), + ("mode", config.mode.clone()), + ("compress", config.compression.clone()), + ("all", if config.all { "1" } else { "0" }.to_string()), + ( + "enabled", + if config.enabled { "1" } else { "0" }.to_string(), + ), + ]; + if let Some(node) = &config.node { + form.push(("node", node.clone())); + } + if let Some(vmid) = &config.vmid { + form.push(("vmid", vmid.clone())); + } + form +} + +/// Formats a byte count as a human-readable size using binary units +/// (`B`/`K`/`M`/`G`/`T`). The largest unit that divides `bytes` evenly is +/// chosen (so whole-gigabyte sizes produce `32G` rather than `32768M`); sizes +/// that are not a whole multiple of any binary unit fall back to gigabytes. +fn human_size(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + const GIB: u64 = MIB * 1024; + const TIB: u64 = GIB * 1024; + + if bytes == 0 { + return "0G".to_string(); + } + if bytes % TIB == 0 { + format!("{}T", bytes / TIB) + } else if bytes % GIB == 0 { + format!("{}G", bytes / GIB) + } else if bytes % MIB == 0 { + format!("{}M", bytes / MIB) + } else if bytes % KIB == 0 { + format!("{}K", bytes / KIB) + } else { + format!("{}G", bytes as f64 / GIB as f64) + } +} + +/// Parses a human-readable size (`32G`, `500M`, `2T`, case-insensitive) into +/// bytes. Returns `0` when the string cannot be parsed. +fn parse_human_size(s: &str) -> u64 { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + + let s = s.trim(); + if s.is_empty() { + return 0; + } + let unit_start = s.find(|c: char| c.is_ascii_alphabetic()).unwrap_or(s.len()); + let (number, unit) = s.split_at(unit_start); + let number: f64 = match number.trim().parse::() { + Ok(n) if n.is_finite() && n >= 0.0 => n, + _ => return 0, + }; + let multiplier = match unit.trim().to_ascii_uppercase().as_str() { + "T" => GIB * 1024.0, + "G" => GIB, + "M" => MIB, + "K" => KIB, + "B" | "" => 1.0, + _ => return 0, + }; + (number * multiplier) as u64 +} + +/// Extracts `(storage, size_bytes, format)` from a disk config value such as +/// `local-lvm:vm-100-disk-0,size=32G,format=qcow2`. The size attribute is +/// optional (existing volumes have no size); a missing `size` yields `0`. +fn parse_disk_attrs(value: &str) -> (String, u64, String) { + let first_segment = value.split(',').next().unwrap_or(""); + let storage = first_segment.split(':').next().unwrap_or("").to_string(); + let mut size = 0u64; + let mut format = String::new(); + for attr in value.split(',') { + if let Some((key, val)) = attr.split_once('=') { + match key.trim() { + "size" => size = parse_human_size(val), + "format" => format = val.trim().to_string(), + _ => {} + } + } + } + (storage, size, format) +} + +/// True when `key` is a VM disk config key (`scsi0`, `virtio1`, `ide2`, +/// `sata0`, `nvme0`, ...). +fn is_disk_key(key: &str) -> bool { + const BUSES: [&str; 5] = ["scsi", "virtio", "ide", "sata", "nvme"]; + BUSES.iter().any(|bus| { + key.strip_prefix(bus).map_or(false, |suffix| { + !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()) + }) + }) +} + +/// True when `key` is a NIC config key (`net0`, `net1`, ...). +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()) + }) +} + +/// Returns the lowest unused slot index for `bus` (e.g. `scsi`, `net`) based +/// on the config keys already present in the API response. +fn first_free_bus_index(config: &serde_json::Value, bus: &str) -> u32 { + let mut index = 0u32; + loop { + let key = format!("{}{}", bus, index); + if config.get(&key).is_none() { + return index; + } + index += 1; + } +} + +/// The parsed pieces of a NIC config value such as +/// `virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1`. +struct ParsedNetValue { + model: String, + macaddr: String, + bridge: Option, + tag: Option, + firewall: Option, + 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. +fn parse_net_value(value: &str) -> ParsedNetValue { + let mut model = String::new(); + let mut macaddr = String::new(); + let mut bridge = None; + let mut tag = None; + let mut firewall = None; + let mut link_down = None; + + 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(); + } + } + for segment in segments { + if let Some((attr, val)) = segment.split_once('=') { + let val = val.trim(); + match attr.trim() { + "bridge" => bridge = Some(val.to_string()), + "tag" => tag = val.parse().ok(), + "firewall" => firewall = val.parse().ok(), + "link_down" => link_down = val.parse().ok(), + _ => {} + } + } + } + + ParsedNetValue { + model, + macaddr, + bridge, + tag, + firewall, + link_down, + } +} + +/// 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. +fn reencode_nic_value(current: &str, config: &EditNICConfig) -> String { + let parsed = parse_net_value(current); + let bridge = config.bridge.clone().or(parsed.bridge); + 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 { + value.push_str(&format!(",bridge={}", bridge)); + } + if let Some(tag) = tag { + value.push_str(&format!(",tag={}", tag)); + } + if let Some(firewall) = firewall { + value.push_str(&format!(",firewall={}", firewall)); + } + value +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index ba71f9b..35278c5 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -12,6 +12,9 @@ pub enum Error { #[error("HTTP request failed: {0}")] HttpError(#[from] reqwest::Error), + #[error("Cannot connect to server: {0}")] + ConnectionFailed(String), + #[error("Certificate error: {0}")] CertificateError(String), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa998b9..dfa0701 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use std::sync::Arc; use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::tray::TrayIconBuilder; @@ -6,12 +7,19 @@ use tauri::Manager; use tokio::sync::RwLock; mod connection; -mod proxmox; mod error; +mod proxmox; +pub mod tls; mod websocket; -use connection::ConnectionManager; -use error::Error; +pub use connection::{ + api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult, +}; +pub use error::Error; +pub use proxmox::{ + AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig, + RestoreConfig, +}; use websocket::WebSocketManager; pub type Result = std::result::Result; @@ -23,13 +31,36 @@ pub struct ConnectionConfig { pub name: String, pub primary: EndpointConfig, pub fallbacks: Vec, + #[serde(skip_serializing_if = "Option::is_none")] pub cert_fingerprint: Option, pub trusted: bool, + #[serde(default)] + pub accept_untrusted: bool, pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] pub cluster_name: Option, pub is_cluster: bool, pub auth_mode: String, pub username: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub nodes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cluster_id: Option, +} + +/// A node discovered in the cluster connected through a +/// [`ConnectionConfig`]. `url` is the endpoint URL derived from the +/// connection's primary endpoint (same scheme and port, host replaced with +/// the node's cluster IP or name); `is_primary` marks the node that the +/// connection is anchored on. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveredNode { + pub name: String, + pub url: String, + pub status: String, + pub is_primary: bool, + pub local: bool, } #[derive(Clone, Serialize, Deserialize)] @@ -37,6 +68,7 @@ pub struct ConnectionConfig { pub struct EndpointConfig { pub url: String, pub node: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, } @@ -48,7 +80,38 @@ pub struct LoginResult { pub csrf_token: String, } -#[derive(Clone, Serialize, Deserialize)] +/// The outcome of a `connect` attempt. A standalone success reports +/// `merged_into: None`; connecting to a cluster already represented by another +/// connection folds the new endpoint into it and reports +/// `merged_into: Some(target)`. When no endpoint is reachable the connection +/// is still tracked and reported with `status: "failed"` instead of an error. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectResult { + /// The effective connection id to use after the connect (the merge target + /// when this connect merged into an existing same-cluster connection). + pub connection_id: String, + /// `Some(target)` when this connect merged into an existing connection. + pub merged_into: Option, + /// `"connected"` | `"failover"` | `"failed"`. + pub status: String, +} + +/// A snapshot of a connection's runtime state for the status bar: the +/// effective status, the primary and currently-serving endpoints, and the last +/// discovered node list. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionStatusInfo { + pub connection_id: String, + /// `"connected"` | `"failover"` | `"failed"` | `"disconnected"`. + pub status: String, + pub primary_url: String, + pub current_endpoint_url: String, + pub nodes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CertificateInfo { pub fingerprint: String, @@ -64,38 +127,91 @@ struct AppState { ws_manager: Arc>, } +/// Returns the path of the persisted connections file. +/// +/// Lives at `{app_config_dir}/proxmoxdesktop/connections.json`; the parent +/// directory is created lazily when the file is first written. +fn connections_file(app: &tauri::AppHandle) -> Result { + let dir = app.path().app_config_dir()?; + Ok(dir.join("proxmoxdesktop").join("connections.json")) +} + +#[tauri::command] +async fn load_connections( + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let mut manager = state.connection_manager.write().await; + let path = connections_file(&app)?; + manager.load_connections(&path).await +} + #[tauri::command] async fn add_connection( state: tauri::State<'_, AppState>, config: ConnectionConfig, + app: tauri::AppHandle, ) -> Result<()> { let mut manager = state.connection_manager.write().await; - manager.add_connection(config).await + let path = connections_file(&app)?; + manager.add_connection(config, &path).await } #[tauri::command] async fn remove_connection( state: tauri::State<'_, AppState>, id: String, + app: tauri::AppHandle, ) -> Result<()> { let mut manager = state.connection_manager.write().await; - manager.remove_connection(&id).await + let path = connections_file(&app)?; + manager.remove_connection(&id, &path).await +} + +#[tauri::command] +async fn update_connection( + state: tauri::State<'_, AppState>, + config: ConnectionConfig, + app: tauri::AppHandle, +) -> Result<()> { + let mut manager = state.connection_manager.write().await; + let path = connections_file(&app)?; + manager.update_connection(config, &path).await } #[tauri::command] async fn connect_to_server( state: tauri::State<'_, AppState>, id: String, -) -> Result<()> { + app: tauri::AppHandle, +) -> Result { let mut manager = state.connection_manager.write().await; - manager.connect(&id).await + let path = connections_file(&app)?; + manager.connect(&id, &path).await } #[tauri::command] -async fn disconnect_from_server( +async fn get_connection_status( + state: tauri::State<'_, AppState>, + connection_id: String, +) -> Result { + let mut manager = state.connection_manager.write().await; + manager.status_info(&connection_id).await +} + +#[tauri::command] +async fn set_active_connection( state: tauri::State<'_, AppState>, id: String, + app: tauri::AppHandle, ) -> Result<()> { + let mut manager = state.connection_manager.write().await; + let path = connections_file(&app)?; + manager.set_active_connection(id, &path).await +} + +#[tauri::command] +async fn disconnect_from_server(state: tauri::State<'_, AppState>, id: String) -> Result<()> { let mut manager = state.connection_manager.write().await; manager.disconnect(&id).await } @@ -114,9 +230,11 @@ async fn trust_certificate( state: tauri::State<'_, AppState>, id: String, fingerprint: String, + app: tauri::AppHandle, ) -> Result<()> { - let manager = state.connection_manager.read().await; - manager.trust_certificate(&id, &fingerprint).await + let mut manager = state.connection_manager.write().await; + let path = connections_file(&app)?; + manager.trust_certificate(&id, &fingerprint, &path).await } #[tauri::command] @@ -164,7 +282,9 @@ async fn get_storage_detail( storage: String, ) -> Result { let manager = state.connection_manager.read().await; - manager.get_storage_detail(&connection_id, &node, &storage).await + manager + .get_storage_detail(&connection_id, &node, &storage) + .await } #[tauri::command] @@ -191,9 +311,12 @@ async fn start_vm( connection_id: String, node: String, vmid: u32, + vm_type: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.start_vm(&connection_id, &node, vmid).await + manager + .start_vm(&connection_id, &node, vmid, &vm_type) + .await } #[tauri::command] @@ -202,9 +325,10 @@ async fn stop_vm( connection_id: String, node: String, vmid: u32, + vm_type: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.stop_vm(&connection_id, &node, vmid).await + manager.stop_vm(&connection_id, &node, vmid, &vm_type).await } #[tauri::command] @@ -213,9 +337,12 @@ async fn shutdown_vm( connection_id: String, node: String, vmid: u32, + vm_type: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.shutdown_vm(&connection_id, &node, vmid).await + manager + .shutdown_vm(&connection_id, &node, vmid, &vm_type) + .await } #[tauri::command] @@ -224,9 +351,12 @@ async fn reboot_vm( connection_id: String, node: String, vmid: u32, + vm_type: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.reboot_vm(&connection_id, &node, vmid).await + manager + .reboot_vm(&connection_id, &node, vmid, &vm_type) + .await } #[tauri::command] @@ -235,9 +365,12 @@ async fn suspend_vm( connection_id: String, node: String, vmid: u32, + vm_type: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.suspend_vm(&connection_id, &node, vmid).await + manager + .suspend_vm(&connection_id, &node, vmid, &vm_type) + .await } #[tauri::command] @@ -246,9 +379,12 @@ async fn resume_vm( connection_id: String, node: String, vmid: u32, + vm_type: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.resume_vm(&connection_id, &node, vmid).await + manager + .resume_vm(&connection_id, &node, vmid, &vm_type) + .await } #[tauri::command] @@ -257,9 +393,12 @@ async fn get_disks( connection_id: String, node: String, vmid: u32, + vm_type: String, ) -> Result> { let manager = state.connection_manager.read().await; - manager.get_disks(&connection_id, &node, vmid).await + manager + .get_disks(&connection_id, &node, vmid, &vm_type) + .await } #[tauri::command] @@ -268,10 +407,13 @@ async fn add_disk( connection_id: String, node: String, vmid: u32, + vm_type: String, config: proxmox::AddDiskConfig, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.add_disk(&connection_id, &node, vmid, config).await + manager + .add_disk(&connection_id, &node, vmid, &vm_type, config) + .await } #[tauri::command] @@ -280,11 +422,14 @@ async fn resize_disk( connection_id: String, node: String, vmid: u32, + vm_type: String, disk: String, size: u64, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.resize_disk(&connection_id, &node, vmid, &disk, size).await + manager + .resize_disk(&connection_id, &node, vmid, &vm_type, &disk, size) + .await } #[tauri::command] @@ -293,10 +438,13 @@ async fn remove_disk( connection_id: String, node: String, vmid: u32, + vm_type: String, disk: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.remove_disk(&connection_id, &node, vmid, &disk).await + manager + .remove_disk(&connection_id, &node, vmid, &vm_type, &disk) + .await } #[tauri::command] @@ -305,11 +453,14 @@ async fn move_disk( connection_id: String, node: String, vmid: u32, + vm_type: String, disk: String, storage: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.move_disk(&connection_id, &node, vmid, &disk, &storage).await + manager + .move_disk(&connection_id, &node, vmid, &vm_type, &disk, &storage) + .await } #[tauri::command] @@ -318,9 +469,12 @@ async fn get_network_interfaces( connection_id: String, node: String, vmid: u32, + vm_type: String, ) -> Result> { let manager = state.connection_manager.read().await; - manager.get_network_interfaces(&connection_id, &node, vmid).await + manager + .get_network_interfaces(&connection_id, &node, vmid, &vm_type) + .await } #[tauri::command] @@ -329,10 +483,13 @@ async fn add_nic( connection_id: String, node: String, vmid: u32, + vm_type: String, config: proxmox::AddNICConfig, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.add_nic(&connection_id, &node, vmid, config).await + manager + .add_nic(&connection_id, &node, vmid, &vm_type, config) + .await } #[tauri::command] @@ -341,11 +498,14 @@ async fn edit_nic( connection_id: String, node: String, vmid: u32, + vm_type: String, nic: String, config: proxmox::EditNICConfig, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.edit_nic(&connection_id, &node, vmid, &nic, config).await + manager + .edit_nic(&connection_id, &node, vmid, &vm_type, &nic, config) + .await } #[tauri::command] @@ -354,10 +514,13 @@ async fn remove_nic( connection_id: String, node: String, vmid: u32, + vm_type: String, nic: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.remove_nic(&connection_id, &node, vmid, &nic).await + manager + .remove_nic(&connection_id, &node, vmid, &vm_type, &nic) + .await } #[tauri::command] @@ -366,9 +529,12 @@ async fn get_snapshots( connection_id: String, node: String, vmid: u32, + vm_type: String, ) -> Result> { let manager = state.connection_manager.read().await; - manager.get_snapshots(&connection_id, &node, vmid).await + manager + .get_snapshots(&connection_id, &node, vmid, &vm_type) + .await } #[tauri::command] @@ -377,10 +543,13 @@ async fn create_snapshot( connection_id: String, node: String, vmid: u32, + vm_type: String, config: proxmox::CreateSnapshotConfig, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.create_snapshot(&connection_id, &node, vmid, config).await + manager + .create_snapshot(&connection_id, &node, vmid, &vm_type, config) + .await } #[tauri::command] @@ -389,10 +558,13 @@ async fn delete_snapshot( connection_id: String, node: String, vmid: u32, + vm_type: String, name: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.delete_snapshot(&connection_id, &node, vmid, &name).await + manager + .delete_snapshot(&connection_id, &node, vmid, &vm_type, &name) + .await } #[tauri::command] @@ -401,10 +573,13 @@ async fn rollback_snapshot( connection_id: String, node: String, vmid: u32, + vm_type: String, name: String, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.rollback_snapshot(&connection_id, &node, vmid, &name).await + manager + .rollback_snapshot(&connection_id, &node, vmid, &vm_type, &name) + .await } #[tauri::command] @@ -413,11 +588,14 @@ async fn migrate_vm( connection_id: String, node: String, vmid: u32, + vm_type: String, target_node: String, online: bool, ) -> Result<()> { let manager = state.connection_manager.read().await; - manager.migrate_vm(&connection_id, &node, vmid, &target_node, online).await + manager + .migrate_vm(&connection_id, &node, vmid, &vm_type, &target_node, online) + .await } // Authentication commands @@ -429,7 +607,9 @@ async fn login_with_password( password: String, ) -> Result { let manager = state.connection_manager.read().await; - manager.login_with_password(&url, &username, &password).await + manager + .login_with_password(&url, &username, &password) + .await } #[tauri::command] @@ -443,10 +623,7 @@ async fn login_with_token( } #[tauri::command] -async fn logout( - state: tauri::State<'_, AppState>, - connection_id: String, -) -> Result<()> { +async fn logout(state: tauri::State<'_, AppState>, connection_id: String) -> Result<()> { let manager = state.connection_manager.read().await; manager.logout(&connection_id).await } @@ -461,7 +638,7 @@ async fn get_stored_credentials( } // Console proxy types -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VNCProxyResponse { pub ticket: String, @@ -469,7 +646,7 @@ pub struct VNCProxyResponse { pub cert: String, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TermProxyResponse { pub ticket: String, @@ -554,7 +731,9 @@ async fn get_backups( storage: Option, ) -> Result> { let manager = state.connection_manager.read().await; - manager.get_backups(&connection_id, storage.as_deref()).await + manager + .get_backups(&connection_id, storage.as_deref()) + .await } #[tauri::command] @@ -689,10 +868,14 @@ pub fn run() { ws_manager: Arc::new(RwLock::new(WebSocketManager::new())), }) .invoke_handler(tauri::generate_handler![ + load_connections, add_connection, remove_connection, + update_connection, + set_active_connection, connect_to_server, disconnect_from_server, + get_connection_status, login_with_password, login_with_token, logout, @@ -759,23 +942,21 @@ pub fn run() { .tooltip("ProxmoxDesktop") .icon(app.default_window_icon().cloned().expect("no default icon")) .menu(&menu) - .on_menu_event(move |app, event| { - match event.id.as_ref() { - "show_hide" => { - if let Some(window) = app.get_webview_window("main") { - if window.is_visible().unwrap_or(false) { - let _ = window.hide(); - } else { - let _ = window.show(); - let _ = window.set_focus(); - } + .on_menu_event(move |app, event| match event.id.as_ref() { + "show_hide" => { + if let Some(window) = app.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); } } - "quit" => { - app.exit(0); - } - _ => {} } + "quit" => { + app.exit(0); + } + _ => {} }) .build(app)?; diff --git a/src-tauri/src/proxmox.rs b/src-tauri/src/proxmox.rs index d8b379c..44030dc 100644 --- a/src-tauri/src/proxmox.rs +++ b/src-tauri/src/proxmox.rs @@ -7,6 +7,7 @@ pub struct Disk { pub size: u64, pub storage: String, pub format: String, + #[serde(default)] pub usage: Option, } @@ -21,7 +22,9 @@ pub struct AddDiskConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Node { + #[serde(default)] pub node: String, + #[serde(default)] pub status: String, pub cpu: f64, pub maxcpu: u32, @@ -38,25 +41,44 @@ pub struct Node { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VM { + #[serde(default)] pub vmid: u32, + #[serde(default)] pub name: Option, + #[serde(default)] pub status: String, pub r#type: String, + #[serde(default)] pub node: String, + #[serde(default)] pub cpu: f64, + #[serde(default)] pub cpus: 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 netin: u64, + #[serde(default)] pub netout: u64, + #[serde(default)] pub diskread: u64, + #[serde(default)] pub diskwrite: u64, + #[serde(default)] pub pid: Option, + #[serde(default)] pub template: Option, + #[serde(default)] pub lock: Option, + #[serde(default)] pub tags: Option, } @@ -66,12 +88,19 @@ pub struct Storage { pub storage: String, pub r#type: String, pub content: String, + #[serde(default)] pub active: u32, + #[serde(default)] pub enabled: u32, + #[serde(default)] pub shared: u32, + #[serde(default)] pub used: u64, + #[serde(default)] pub total: u64, + #[serde(default)] pub avail: u64, + #[serde(default)] pub node: String, } @@ -79,15 +108,19 @@ pub struct Storage { #[serde(rename_all = "camelCase")] pub struct Task { pub upid: String, + #[serde(default)] pub node: String, pub pid: u32, pub pstart: u64, pub starttime: u64, + #[serde(default)] pub endtime: Option, pub r#type: String, pub id: String, pub user: String, + #[serde(default)] pub status: Option, + #[serde(default)] pub exitstatus: Option, } @@ -114,9 +147,11 @@ pub struct ClusterNode { #[serde(rename_all = "camelCase")] pub struct Snapshot { pub name: String, + #[serde(default)] pub description: String, pub snaptime: u64, pub vmstate: u32, + #[serde(default)] pub parent: Option, } @@ -140,9 +175,13 @@ pub struct NetworkInterface { pub name: String, pub model: String, pub macaddr: String, + #[serde(default)] pub bridge: Option, + #[serde(default)] pub tag: Option, + #[serde(default)] pub firewall: Option, + #[serde(default)] pub link_down: Option, } @@ -189,10 +228,15 @@ pub struct BackupJob { pub schedule: String, pub all: u32, pub enabled: u32, + #[serde(default)] pub node: Option, + #[serde(default)] pub vmid: Option, + #[serde(default)] pub compress: Option, + #[serde(default)] pub mode: Option, + #[serde(default)] pub quiet: Option, } @@ -224,8 +268,11 @@ pub struct RestoreConfig { pub struct StorageContent { pub content: String, pub ctime: u64, + #[serde(default)] pub format: Option, + #[serde(default)] pub size: Option, + #[serde(default)] pub subtype: Option, pub volid: String, } @@ -236,11 +283,18 @@ pub struct StorageDetail { pub storage: String, pub r#type: String, pub content: String, + #[serde(default)] pub active: u32, + #[serde(default)] pub enabled: u32, + #[serde(default)] pub shared: u32, + #[serde(default)] pub used: u64, + #[serde(default)] pub total: u64, + #[serde(default)] pub avail: u64, + #[serde(default)] pub node: String, } diff --git a/src-tauri/src/tls.rs b/src-tauri/src/tls.rs new file mode 100644 index 0000000..f9c3957 --- /dev/null +++ b/src-tauri/src/tls.rs @@ -0,0 +1,218 @@ +//! Application-layer TLS certificate capture and TOFU (Trust On First Use) +//! fingerprint pinning. +//! +//! The reqwest transport used by the rest of the app is configured to accept +//! self-signed and otherwise invalid certificates, which is typical for +//! home-lab Proxmox servers. To keep connections safe without a full CA trust +//! chain, this module performs its own certificate capture at the connect +//! layer: it opens a raw TLS connection, extracts the presented leaf +//! certificate, and computes its SHA-256 fingerprint. [`verify_server_certificate`] +//! then compares that fingerprint against the value pinned when the server was +//! first trusted. + +use crate::{CertificateInfo, Error}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName}; +use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio_rustls::TlsConnector; +use url::Url; +use x509_cert::der::Decode; + +/// Verifier that accepts every presented certificate chain. +/// +/// The actual security decision is deferred to the application layer: the leaf +/// 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; + +impl ServerCertVerifier for AcceptAllVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +/// Installs the ring crypto provider once per process. +/// +/// `install_default` is idempotent and thread-safe; subsequent calls return +/// `Err` with the already-installed provider, which is ignored here. +fn ensure_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +/// Opens a raw TLS connection to `url` and returns the DER-encoded leaf +/// certificate presented by the server. +async fn capture_leaf_certificate_der(url: &str) -> crate::Result> { + ensure_crypto_provider(); + + let parsed = + Url::parse(url).map_err(|e| Error::InvalidUrl(format!("Invalid URL '{}': {}", url, e)))?; + if parsed.scheme() != "https" { + return Err(Error::InvalidUrl(format!( + "Only https URLs are supported, got '{}://'", + parsed.scheme() + ))); + } + let host = parsed + .host_str() + .ok_or_else(|| Error::InvalidUrl(format!("URL '{}' has no host", url)))? + .to_string(); + let port = parsed.port().unwrap_or(443); + + let server_name = ServerName::try_from(host.clone()).map_err(|e| { + Error::InvalidUrl(format!( + "Invalid hostname '{}' in URL '{}': {}", + host, url, e + )) + })?; + + let addr = format!("{}:{}", host, port); + let addresses = tokio::net::lookup_host(&addr) + .await + .map_err(|e| Error::InvalidUrl(format!("Cannot resolve '{}': {}", addr, e)))? + .collect::>(); + let address = addresses + .first() + .ok_or_else(|| Error::InvalidUrl(format!("Cannot resolve host '{}'", addr)))?; + + let tcp = TcpStream::connect(address) + .await + .map_err(|e| Error::CertificateError(format!("Cannot connect to '{}': {}", addr, e)))?; + + let config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAllVerifier)) + .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 (_, session) = tls.get_ref(); + let leaf = session + .peer_certificates() + .and_then(|certs| certs.first()) + .ok_or_else(|| { + Error::CertificateError(format!("Server '{}' presented no TLS certificate", addr)) + })?; + + Ok(leaf.as_ref().to_vec()) +} + +/// Computes the SHA-256 fingerprint of a DER-encoded certificate in the +/// `AA:BB:CC:...` uppercase colon-separated format used across the app. +fn fingerprint_of(der: &[u8]) -> String { + let digest = Sha256::digest(der); + let hex = hex::encode_upper(digest); + hex.as_bytes() + .chunks(2) + .map(|pair| std::str::from_utf8(pair).expect("hex digits are ASCII")) + .collect::>() + .join(":") +} + +/// Captures just the SHA-256 fingerprint of the certificate presented by `url`. +pub async fn capture_fingerprint(url: &str) -> crate::Result { + let der = capture_leaf_certificate_der(url).await?; + Ok(fingerprint_of(&der)) +} + +/// Compares a captured fingerprint against a pinned value. +/// +/// The comparison is case-insensitive and ignores the `:` (and other +/// non-hex) separators, so `ab:cd` and `AB:CD` (or even `abcd`) all match. +pub fn verify_pin(fingerprint: &str, pinned: &str) -> bool { + fn normalize(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_hexdigit()) + .flat_map(char::to_uppercase) + .collect() + } + normalize(fingerprint) == normalize(pinned) +} + +/// Connects to `url`, captures the presented leaf certificate, and returns a +/// [`CertificateInfo`] with its fingerprint and parsed metadata. +pub async fn fetch_certificate_info(url: &str) -> crate::Result { + let der = capture_leaf_certificate_der(url).await?; + let cert = x509_cert::Certificate::from_der(&der) + .map_err(|e| Error::CertificateError(format!("Failed to parse certificate: {}", e)))?; + + let tbs = &cert.tbs_certificate; + let issuer = tbs.issuer.to_string(); + let subject = tbs.subject.to_string(); + let self_signed = issuer == subject; + + Ok(CertificateInfo { + fingerprint: fingerprint_of(&der), + issuer, + subject, + valid_from: tbs.validity.not_before.to_string(), + valid_to: tbs.validity.not_after.to_string(), + self_signed, + }) +} + +/// Captures the certificate fingerprint of `url` and verifies it against the +/// pinned fingerprint recorded on first use. +/// +/// * `pinned == None`: first use — returns the fingerprint so the caller can +/// record it (the TOFU trust step). +/// * `pinned == Some(...)` and it matches the captured fingerprint: `Ok`. +/// * `pinned == Some(...)` and it does not match: returns +/// [`Error::CertificateError`] unless `accept_untrusted` is set, in which +/// case the mismatch is ignored (escape hatch). +/// +/// The returned value is the captured fingerprint. +pub async fn verify_server_certificate( + url: &str, + pinned: Option<&str>, + accept_untrusted: bool, +) -> crate::Result { + let fingerprint = capture_fingerprint(url).await?; + if let Some(pinned) = pinned { + if !verify_pin(&fingerprint, pinned) && !accept_untrusted { + return Err(Error::CertificateError(format!( + "Certificate fingerprint changed. Expected '{}' but got '{}'. \ + This could be a man-in-the-middle attack.", + pinned, fingerprint + ))); + } + } + Ok(fingerprint) +} diff --git a/src-tauri/src/websocket.rs b/src-tauri/src/websocket.rs index f2c7fc3..4ee3a9e 100644 --- a/src-tauri/src/websocket.rs +++ b/src-tauri/src/websocket.rs @@ -4,8 +4,8 @@ use std::collections::HashMap; use tauri::Emitter; use tokio::sync::mpsc; use tokio::time::{sleep, Duration}; -use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; use crate::error::Error; @@ -236,10 +236,7 @@ fn handle_ws_message(connection_id: &str, text: &str, app_handle: &tauri::AppHan .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(), - vmid: data - .get("vmid") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32, + vmid: data.get("vmid").and_then(|v| v.as_u64()).unwrap_or(0) as u32, status: data .get("status") .and_then(|v| v.as_str()) diff --git a/src-tauri/target/.rustc_info.json b/src-tauri/target/.rustc_info.json index 7e9bb2c..d63ba35 100644 --- a/src-tauri/target/.rustc_info.json +++ b/src-tauri/target/.rustc_info.json @@ -1 +1 @@ -{"rustc_fingerprint":6524788087311550793,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: aarch64-apple-darwin\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/matt/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}} \ No newline at end of file +{"rustc_fingerprint":9200552825474144316,"outputs":{"7752308220390268658":{"success":true,"status":"","code":0,"stdout":"rustc 1.97.1 (8bab26f4f 2026-07-14)\nbinary: rustc\ncommit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452\ncommit-date: 2026-07-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 22.1.6\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}} \ No newline at end of file diff --git a/src-tauri/target/debug/.fingerprint/libc-19124a20af635abc/output-build-script-build-script-build b/src-tauri/target/debug/.fingerprint/libc-19124a20af635abc/output-build-script-build-script-build deleted file mode 100644 index 3eae485..0000000 --- a/src-tauri/target/debug/.fingerprint/libc-19124a20af635abc/output-build-script-build-script-build +++ /dev/null @@ -1,2 +0,0 @@ -{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"} -{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"} diff --git a/src-tauri/target/debug/.fingerprint/proc-macro2-28cd7e73fe64eada/output-build-script-build-script-build b/src-tauri/target/debug/.fingerprint/proc-macro2-28cd7e73fe64eada/output-build-script-build-script-build deleted file mode 100644 index 3eae485..0000000 --- a/src-tauri/target/debug/.fingerprint/proc-macro2-28cd7e73fe64eada/output-build-script-build-script-build +++ /dev/null @@ -1,2 +0,0 @@ -{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"} -{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"} diff --git a/src-tauri/target/debug/.fingerprint/quote-6dff9724e4e81362/output-build-script-build-script-build b/src-tauri/target/debug/.fingerprint/quote-6dff9724e4e81362/output-build-script-build-script-build deleted file mode 100644 index 3eae485..0000000 --- a/src-tauri/target/debug/.fingerprint/quote-6dff9724e4e81362/output-build-script-build-script-build +++ /dev/null @@ -1,2 +0,0 @@ -{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"} -{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"} diff --git a/src-tauri/target/debug/.fingerprint/serde_core-e613c711ddfe8493/output-build-script-build-script-build b/src-tauri/target/debug/.fingerprint/serde_core-e613c711ddfe8493/output-build-script-build-script-build deleted file mode 100644 index 3eae485..0000000 --- a/src-tauri/target/debug/.fingerprint/serde_core-e613c711ddfe8493/output-build-script-build-script-build +++ /dev/null @@ -1,2 +0,0 @@ -{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"} -{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"} diff --git a/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.3ufr5450lp20sxhbw3b5lnbvs.rcgu.o b/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.3ufr5450lp20sxhbw3b5lnbvs.rcgu.o deleted file mode 100644 index ba2de300ea59dd3907f9098e7e478dba42fd9bda..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2560 zcmbVN&x_Mg5PsRN?y8H_#e=XOauO99(qG%H2Q8?pq7+>f_8`QjO=&T0N|JVKFIz!G zJo%T%Uc`$B(To3u-o(@5LGU1~GwGY=HT%+T9SCpc%Qx?vndBwAH}7pOC<-G}*i{xr zgfjN&Sm>7mx5Ul>^JfSL-n{V_=5{^5s`f0`YdUQ;trZJeTD`boR?N(LJy%yVdS*SP z7gFimB~Uoa^_0ODQ~F6|ZDncsEfkyt{5-!$r6Q(Fup1I=JA$E(%`w?L_^t7RT8r%0 z!59#n1WO1}=q4Cd8e-@r*wQ4xqXat}5$Xu|aqb6;=39NAX;r7yvf4h&cI$3Fm)Gr1M)&#+ zb0=%&?Y7zTnAUcDOS@Hmobvoi^%>J#%dTiv!>GHJmc>KdOf$C1z3X0aXY+yEs}8ok zyGFqJzbnJ^Ncsg@%(*9!Ee3T3$vap%>j&7ncu zBWh%*kJ(+M#c?ss^IgAU!uq@wtg}Xz&68By;Uo7N&6d&hjBXnqylGoTFC)G{|L1>$ zgCoArw99J_7Q{mUH|d-a4`aM0-erhOaLG(OBY6KMBu~p1&kpkOJ1g2QB;%eX5d?pa z*L{xTn1?OlHv^4uHAesbh&FL{5sK=s#pr*P=wmNY{f~gj`hN;I+N}OR07usUcZoj! zRigT3z-0Ozz|m&u-vN$H|Bpl;zy4AE{h0X=fs2N&=_$(^b diff --git a/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.build_script_build.a30a70663bab765a-cgu.0.rcgu.o b/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.build_script_build.a30a70663bab765a-cgu.0.rcgu.o deleted file mode 100644 index a91ef612b527ed36a4f3847389cb0229465c2549..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 188848 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 diff --git a/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.build_script_build.a30a70663bab765a-cgu.1.rcgu.o b/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.build_script_build.a30a70663bab765a-cgu.1.rcgu.o deleted file mode 100644 index 2c0bc9d4574c47fa5b5c1d8aeb8b78b91b09cbac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 135328 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 c{j++Tm(Q;WzITIBMH#=kW&>v5=t<8 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 361992b..a16f66d 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/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/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/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/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/.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.1cb4wof2950oslyk39975h0vc.rcgu.o b/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.1cb4wof2950oslyk39975h0vc.rcgu.o deleted file mode 100644 index 1c0c0ef0fe09d59df842bb007b868017ee8624d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2560 zcmbVNzi-n(6h1eF7N}6D7?8@~1q4HkogYoZfFc0}2~h5rIJ#8fdLF(8%q?0fOWJvS{+vhKal-@EU<^WAy(?!(m?MPXzL zyT#&&P{ux;iv4QnR@nt${*2+!J8Ardd0T#9seRk?+iq7iv`Ses)N3pCT0OtqEH>4= zo?p)C<(yHx4hk2!o-$l#${?#OEzHfofr7JuU*_+qRK#=%c1MD3Brw!*I3`;U{%SnD z1#g!9Iv4|jlVKSliroyON@EPY44az-c$8shQt)$!@YhiEqM%Dk?{TeW4$;}wkWVZ& zuIxz)|1(UnDAY-r`+&>lzZvr5`iYNgEaU$U>xuhJ_1$fS=}mIB0=A2$*KMosQiFr|Sl`c5m%z z&JSwVCeu9IscCl0Y`9+`<}1mC}eMgOZrAA3pae*{d{|8v06ruF{; zII{k~OZ4%tlGMk0O{U)i9BrEZ4dBT1|48)l_D|~Xr>y@7xM*0LXvy`*o{*!9B=BGG zIG4a_J{oa8@T1 SprE8Srs%xT5g&1oegA*LRwPdV diff --git a/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.build_script_build.3e289dc730a2dac7-cgu.0.rcgu.o b/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.build_script_build.3e289dc730a2dac7-cgu.0.rcgu.o deleted file mode 100644 index d8c6fca24d5545a4c0b2b8161375571a3f44dc01..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 199744 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 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 0376636..02b6234 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/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/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/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/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/.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.build_script_build.290c8a9cd7fd2ff3-cgu.0.rcgu.o b/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.build_script_build.290c8a9cd7fd2ff3-cgu.0.rcgu.o deleted file mode 100644 index 23764506bf8edfee3b6ba022c1db113d16a6eb04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 119664 zcmeEv37lM2mG`Tv1Uez48wk+s6=MV%T zkf7ak&^TrkMaQ_~HfBadU=V{a5FJM|j-N(9J`IXv+G1v+;}XXu-~WG?SEpXRm-LbV z`tw5S-TS}io_p@O_ny1H*LN>oc4asea;ytEmpl28MIEQVCyzEOxY>CS>EfGSF5ky+ ze0WhsS$u>)@RiV465sS}`7?^x{fXc6@`DHrFRPdmAO5#Eo+a_&UyfuMyo+h?swj^? za92gAW;(JG@y{=+h>}5kcx6Q;Bct(6&zHp?$X0YdQ3c$PBKdQRDprT^lXMsxeIlNZ zT?D@OJj=WbB!c{_;=_MJnk6B!E>X;Kr~QV&Ku7N8`0&%iyD2A&)tdWRoFY9&WyHP~ z-}JvuymZmi@lP?FJ)eaxCtX!(8H!&z2ZEC`q=C?yb2t~yL2J*hdL}+{S4CWdzj6*9 z43*4ipY8OY3Qod7zIc2%OD!E>0z8e1%fY!Jq|y_MDw!KX1M70PxiU8*qM0%yo2hv*Hm=IhD9SJ`dPkk}uF&bE zRSxoRh!4+#_EZ^U6<6wXr!!wu-B#C~khMSeA4=NT&c~@;kFIA@hO+qcDwEu0IyABj zB}7%V#^I}?Zxwxw^sT0^H9m}%Nq9J}JgebBnWt35Hyv=+&tg++E@5u)#G_%y$(>k; z4FaQ%ka}fBGkL{XxfklPAc`$u)3Y1c$WW&DA z+WSf^DCA}xfz`1>En2mn>V#U*wv}!~i$&If4HLx6ShcEOu4EG}$Tc?viQ~^u-Wg|4 zuXQRfaGb00q(gs=Lwfd0w4l4#g06<}Xe?JM;gxQiDLjhBANb0dU1RB&hEJ!mns%j| z-Kb7>jK>K=o`fU{hj=VFO0Csi1#vyf{9xrJ_ug zNt02+a)wcNLg}M#`_sXLR8Q3O1^O#t=~!#3dq#=$vtav`#EU96FmONx@};KLXCA7c zh#xZXJNZO-s|oKiVOmnsE2fCwZo-N$!VjCU;*0POh97uD@y%Q9(d7r}LZ`0(Na!K}IKA3A6Yaj`)N16KcWD2tvj7vkkAzw(WLB9wm$s}#p}C~n z9;gy%fSSntknI6g0xN4oVNk|X-FZEFCj;#;Wkw(Z=esK5DqKeYo zITTPUeDu8}u|`_!_r?1`R1qqDbf*~w&r>DzwrZHy(rr6y4?Y|kSdg2|zF(&DBX5l6 zT4yX{nW_>y>O5cyss^8@K8GaG)C|%^b;W<9^f;>w7$>cXgAbh*I<@@d7N=(VzlTDX z-R3x-44v8HJQIp~wc$9e!@JZx1nOWT`&nG;VG+us`QB78UC{O$b`b4Ojb*GyWld2K z+y0b=g@n3Q2H9{@p&6hpkeh6lS_yI0F3YDkI_IKY#>*S2FDJIMA|xe3eWF_^4#y~s za3ISn4WYdQKe!tT&CkNdOOFwgo#s@Z`Wy&z4s&URQhM%xlz5J!cZsh!sHR5{$(I;O zCc}Tz;n{2c?i4~Kz$C_semuQyBU~lbJ8t>3el6!S@|mD{vEm zbc7}N2P3080h+Rz=;@|RBWZLCtyPNVcZ#dtawLuBewtUPwq3}M_Oa~T8B3jcusxSg zyD7BfTvxAFrq`f6=-<@O6}q2IygE>Rw5j8!p5>f4?e(EE0_(%%m)CRNdcY1Tdqa8r z<6D)K`94$>*7f2NsQ!Qdu@^_urMhxG{jezg;rJgWy_H`p^BfifDRdLbp(Pgd(H9G% z57kf4c21qz96A$o=EC9D8MpkN@)gQE@yZXfCzF-0C_Ni1`tqg&kpXL0r-(Ay^29g& z_n@u=QKmCI`p?@)%tCZct+o~CUsEO;^DxxwJH^{~b~tcXG*+Yg&2Phq7p z?dKd%CWY;XvqP2Vj>G1Vr?5H14_$6InmKHGUe^kg=C36;T2rsmyn|Z5j`nT+w5dIz z&KXl_JHyUUnE0o| zQ+I})$CZCicPlA=+u^6VxKIbrrA&JGUlq8y07tD==M>Yg4S zo>Gx(&#l4+-W8#Njkz9ImCc}z=YGcZ3OHYGCaHsWRa~xTsd^Ld1eM?Y%PKBMS)Ox3 zxkpTClt_B-D~cN9+*cK123i^AisW8m>|GVjx>|pQauzzQGml$**SAB?)1mvZic{{d z`hJD=eb37eL-Fz9T2!06j8mIgXw#)-$EZzP%2Z`{J-8PXAee?$dGQ*UEl|4XrG9zp~s+%wm@kzBqlI z@!@Jd`{ToDMZN(ZFi^WcXr$bI9KHrIE4k@*O&PX)STM0Q!0eS&v;#AnPi%8mPJb~J zdeeK*_S{(t+8>g;ir{iwr6J#PcO6%-&3!f-Id-$M73KMj99_Ik+!EFs#>NVvd+Ld+ z;)+$ZcRf9Ppm@DyW`cStZp4{Vp;Eg13?J;z?1?zebDJx^ga@kU$%;MdskSqopk2}o z5V2E5Jfa8`=&lQOoHPSk3#dI+iQ6hZfV89grXUCGdF>`6v!lapMlxmt*^j4@Bt$y7 z_f=Y{<4LzYPp9~CxF^5e*zqDA<4K26QI+TbYApW1BPy%}&&~ObE^w&yzK0;x=rAR- z?d~yX0^D}uk^7$6(4b<-=80+Gtu+FYtqA1Mes!E?{c&GDXupq`JwR>isZa1z8&~iT zSrPHne3aze;HBh@Hn31SV||pqO>GiFye$xKbQ6j}We$=Rz%qoXbS5q>+kyihKDSl$ z^Eqc*g~RwcTPpTbnRBq6OC=&b(=mNB>7ZodrIm)R(x%&{Hjs`w!to`o918Pt;s zRwWv}gFCQ(y5Cbk$893?VQog(!Wo282T@3O+wIAUN{DHdO25j3X<-m}C<0mSd^N9j6d>BoXfzPbb539O) zQZs-?$iU=<*_zI#gmt=0aW+qcA2eb0M0mFfKdGLI!SJjKZO9vlX8ftM~1o4ZZlSjhNhr_4+d)Rq4 zeEd(QhepGv?oKL{hQ-T9p}TxF+z@CI4@R5lut!% zHu2%-M}I%S!xW{^Sn~{X91tF>A>>LiMgqm(v?${2w$C^o!f7j*7z~a zeb+`;`bPgFQ0?se4z0wVeY!2LVBYB-X!1M+I}zyR7-KR0exebav?@o;0YL$V*@Vk+ zzO<&(D;?1A zZyiO?lWUOer|mMsHpe+V|gJjg$C(9@Y*LunGOXUAa$+`G%thX4@6j6FDVKSk^?CEAD+qc2m1q?4mtzJ zVg+*0$sI?MO$S2RV{{fkI8*ZhmY0aV{_YdxI~9ba+`H{+Q4+?KW+35v?&cu*UE28aj1!tqYZFsC;y#3!SE+(?%D# z(CHdFeRQ4+ouQ#KM$d4eGc|PPXo(A*rJ=J%fBi?RiL*6y_UMmY=o}55Gx{wTifSl2 z`lJiZ*3j(HPr1;!8aj9MJuY;fhRz$^GoN4~l)f=`w56T@|xJ z16ZHnU>9Q=?jMbYNURSZD?-Y~Tg!bt#Ek3nMR&(U%Zole1 zmuxyvvHpkfOxbjxY5noav#*Mh)8fNe zYC_W>Dd9n6foVl%m>6NVC0C^tHF9ai6U=Lw*~uCHt{HKVS}j`L6sYC~2b(LnX%x)W z3=~lg6qU+$gST?L#`M6-diM+y>$k@cfpKFci-nmi7Qv5UYVI`cUOx>5S_vJmI{}t| zrI|bG%3@hD%f)n)wL35aV)Z!q=?>+v?X2NYwv+=YKrD`#i`ueTb-#nbX352`%buoY zol7pB3pzEhkyj{Fo>Sa-j||+t!if;lhH?wZN~gXOsb{FvS0Z&KSNH0!5oOx+G^%4? z$R=Z}H}msw?hD|s64Tg+!H|y0Pzv0Z*>s?6T{x$1Ry9J35(K7>PUq4Eje2z`b28>% zOobBnQ!r=rJQ`h0+>}(hB|zJl{wpEAiXA&%G=LXgM=sCO`#y|3a?KEn(m|?^F;D$KXtNFt>NqME6I-R< zT@g)$YfIM`G=yuYSu`hf9ZbMU8`-9~LIXPxLbZMt50)75L=Zu;d%2r+J+!H^;a`+~-jqr|N*`_92Z7j# zve5hn6ew$MZM=@9gWZ7D1jh|1n$kv53D%?4Q8L{-M%xf1N7unV#m!kREYnV9qF@G= zU}~tehZ)>c$AQ{dUL_8*R(8wSi`&0l9uJwjp;OiJ8)+e%e_HNES=q5NpRqLlz!VG{ zm=`MMxidM7HvaR?IvpJj1kJgZ@^P?IxN)JHCUeq?!cv@fYPN)?ekkO;J2d^NQ0Rl9 znAkq!{kU>eIn5T7^mf|`X?|;N&XsO{52I0fSeVUv;|l>;6n%@0F`W!h6XdW*al;Kf2KJgj zGXshOvc$P0s6vcl+eq>H$)=gU4fMsya8MDftmuD??cIA57OQxb$a^P^no>46%7)gj zmgb7lGVYA%Hp+W%g9H&~Ct`TJ3PT@;L|xuA(YU`k%B^-5Vo=d-R@dq%DwuB8`!}1O zRG>5PR^rIsT&_bV2bc);rFyRM@5hsln*}N!^o-}l!y)2Gx0O$%dys^1WpTYIEWIxYVMpIe z?dW9ja%QMhp_Y>7?jWjwm{WS+BSt`EVblm~wSl@EL<}7pVZZXw7?Es{C~Vd$TGQ2x z@6_>Xbmg5;w5ZjLjMIl!Z>|A1(^p9h4VPzC@U_y{NG4IOXf+)|wW<5V`etyIz8Orq z;duGzG#ENoh%S~C+x7;EY^zO6I|Z{)HbS?`uu%7{D-v?Ls;f%198*=yZuDtX)uF3o zpgea0i&L(3LUU^Fy6bet5SQnEVy3hENLUI(Z0D1MYym?wV_T`_2bm7OMHSoW!wPSQ_zqCaEd3IGDB^yKcpyHE87^oLT2}EeeSnr#?1+y5hvzYJ+DiOE z((fg!Lx)JE^;Il?;gF zJ>fdKzSPeAh6?7+iiJ9##Yp&@B@03WjcQQDfq$8*P;RMIvYsHjn*2Ri#sRKbM`F}y zW&<5KGk0n6dZ3Ew8gq9uTU9IZ_^{S*D^&wcs1{I`Q|}2>YL-ZXbU2dAEupfZKOC!J z^hCC5yG=WwRRLE6Oi%QW_X~<+?3oyxvx_q#Bq@KD;^3(T~YCKcAL42mcyu%_wBibMSDx7u(zSem}l5& zL34ZrZF?Eb*VSe8v{K^I`#!14p_HO5nmX>=RxwBz7IO$sI`;kz%Htj>*rCdt+hFL@ zS!5~qT)E>dl0pCInh)Q#} zX2xYs3B0Fc-Ho=X<^s8kHI9i_y4>Ixe;m9BMG_pdI=N}e8-Z7M`PUY{_!5MDw=TGlDans%_ zX6(nFN{%bk1K|NRKirQiH2dyfSur~J^_@dyxJ#3puA(E^DY^4FTtHGw(tLlm;^)nS z-`v|gR5jEZ8j6Sc-b@j}{dZO5nui*jhx$W9t>K|~gs;+h6r|@fG}f%}=Ai?S>m0m0 zR+c`GIz>wYjGg&&N*_I7R(migXk!?obahuz-MLccA7zqC)yf!DH@d3Q!P{QyHWac@ zqap@4>>*6aoq`)gKv1PKDxi;Z8fTSY#|Wb+oBIg1Mdco(PP=KR+O(<44I5q@lHC^i zc?xJ9%3F>$QNqsm6f1m(ql>laz`=pzjkj?cls-2%m%XdhY@z1Ybn+H1G6gPj~VF(<8$`M zo1R|>!;SISBsdmL-6p_{qR_R*hcRX02~I(pkBdwS(t!SVmhn3Uk>#q}A4I}^xe;(? z-EbZO+k`}-ZoV2j3d8!TPCbWt1`|8vS;Mfj2G5K z>*5koG&YbbnCpWy1DwG;#sJ*iv2VwJiye41hoIGjy{f|g;uo#?JowPG(3JD0&dZ;w z+R|_|;C}NfRi1{UUmm^R%-TS`>{d1bbu^0&8V#F${sHO;L+lW=V9z2ERFfqPtO^F< zDvq)WL|`>jfr;7u)LZ%tZdXhU48l&PQXm5TOa4NU?InmM! zxMx1Z@e#W?ZrZi}`^6Rs@*aqfpv3+9q}y}7 zjPq@EHA%l-vP!*P0u8LTTau^`rOD0uO=(y6mSe6$Jtt z7^qY+wrV)-)Oxq`TibfnqR4Giq|+SqR6Sr_eTI_7val?-RID-Xu^sI!rM()@jzHLPViPd5Vnohrl(P6-ZuSC zPx@UZY}4;H;Xyv37d08g!1Rccq8VxaZj+BzUIP z-j6ih3jL!pK8#s`xi-mimeER;JqGfkoz^VoDeA_*ZvD9i?9G?*>H=QLL}%d}Qo8?? zsnrE$3FdmIeKA1aFaLf48F_?qPpJ37ohF>bO4Rv-jwu7Ic<4bg_bEF>xmhavA>S3#`oW}*E1aS&P~0mov>B~Wm4Me1 zW%$vCuh5)1txquCy*=cKB{$4}MjAvp^CjMd>5_MT0s6QZY*2?7WG?2E00reP1d?7J z`^^)af2&4;>baOU`SLtOJ6RRK=YP3ZyB-Yzb{vxWcKKCFQ{_ZpFcnOV9P4YAwEkD-LdGLd6pQc>Zs_>n70=K>htNnYQ#W& z7RW@4`I@NDr}u`b7O#Bl_^-TtW6_+A7VI=r)jkjTK%}D zoR3qi)%v?&{=DgT+VJ3V5VAxiDw}g@g*KkKpBhg3f%-(JRf|%RL1X3drf+6>eepae zcOf~zU~w(PQ9`poBdNg7P6gMb6(I~iypnmInP3!fN73(j`2e8!u%5NWhxP4WJ|8BI z3Dkq?iQ3|!pR&Z}cy9z}A9k;Jq#0wRne$>B1+6PT0UlHg9x*Giug2%#;|(~9#&*lP z88{SuJEBMLRWvp;H6u3(P{IxI5Rz=G*hwPfxJwb6Fg9CN81Xwy{9`KKZceLh_$}tZ zUL8L^jot7l%d@)l(Pr!y={q-_JZ!MjW;I&a!wPTK`oL#2g4Hk8-uaX=3My5+%l zhS2C%v-x$FK6UWeJ*hbVhU#m%M20%#ilMi!KnKDx9L zn#)vjYr#rulU##Jz^g?@J|6VuFwR=7#*w(SGCoIL)u`gwi)v`(xiLM>`XccnEImZ1_rj=_#z8@`<4W}(Jt(xKKGO|9)^DNt+dDJ-21t0O7{ zq9!1VTPx+NR?vzucRApsXF-&Ask9oIqOv_Hrw*5(6K**-?aH3RH%Vc+IImRmF|&nh zx)t`j{kqEL3SLIQp@4e*FB{IOmb+bL$C;d48?;jHc^|6^QvCo2y_{s2wbeb(vMYJ| zKc+DwHko(;46oA1S-ex@+j#X5FdWd zG>gL70rrawZuecl-;6J=vQX|R`cpW*YtR2cQR%%+i)Ijm$z;WlC2KDEM;BF+m_&Fo_XGjg~c%pI~#_(U5M#HoW- zst}{Zyf?t`3D%LE9&0Xuyg}wSbD;qfHF>`jsBW6VbSurc- zcMCYgLOv{V3b%j0iKOA>{Zww8na}lMA z#?cVwZ=jCx-m8q741@?|9@dZUU3iXL#8s5?pl*hrZ--a?C$PsyCjotyQHnJyF1mE?dt2zT(Tyc zPNgpCO|I)&o6M%>r8CY%`De22ihEbzB|UxZM~G7@o$gCtl1}xf6KlG%{mHi5QBsY)04G-kwx%_K0xQCGSpd>>HRALu+ZMzrKuBpldl3#)l1B=Ry~CoHFAt z5tyGh8SUsx_dt_fy=$Y{bh0ZO?ds|8PEij@TFq!Wl^sa;rrM*)-l(!r(e<6F-e|Tn z6&>hpPo=vzGASw~mF$VOceO`*`?C3*mKyJRtjSKu!@Mt|oPCNYU+>KZ*DsZYm8S2YMj~%*mSWRJ08}%QCa+PeXb&eJ~MLf2$y0l_kdWYU5vpCuWAu zwbx&_a^c*$>r&|ql%htZC@!$O?pWh%OQzTM%}=M&1HE&*I_5%>-nFUr%hn8Zb<>tl zr=^Q_Lw}jhzJc!c=$aHlnGDnk^;Xx_ne-g>kT}(L``osUwU^;vWp7`iy(_u4w=a|J zYD@Gcdr}z;5ve)T3zC31fRLM2c?UHpXP-8vE0a+DAl078_RWtff3z#ZN{uF?{eA0G z>A8I!bF=IFYzqCn#suGl2^964rL=3ivh$-|z3afGJ(^CocWsEyoohTjC?ZSWrJGWk zdWu#@vwhK;dVp=+1DURMZuL2>hX1=EFh9NEbypeSj)>!20n6+W{#ppEyYi<4ar~B5XlRfjIis*V6mX0pU z+mh%DrQyF0KN-7@$0DXXi^LGRpNBzkR@s1tI|pD%_2QI*Q>^rm3Qb)&&x zk!pW!7DG+KOw&v!*C+au>1-z7y*L8fDuLNV7ciDvb&TFVT|>H?kT%;#Q=ytDdn6E* zfoiHqZ(8f>d~;p>XQY-y&p`GsUNtACdO4>_3}ibR=SO>KTy~xmU9*walr^Ygqu0Og znJ6YmQ=F4wU||{uIy%tUJCm8{Z5(rVI)^oirs1tk9oh!Ml&3b(8m#ZiqRZu*)N8uk z6qL%_Y>ajPb1PXkJ%4^dxnx(cBbJ(Bd1@=0R+D$G_Fk1rb7wquzWr6qk4|$@Y|k41 zrNHI<$gUgg(y%<@$72Hib1)A0}S9FS?1~InxAAl_30f-4w?>mFo}J>7|ER zheTuZni}TCqLuCjS+u&Ux_)j|!`xWy9HyCN5{B-))(LGW3!O3J*pjV~k+{wKT+%F> z8GYl-5~m#AT87~^^JmG-?V<2ph|y`RzRqLZf|+GW#FW9?$uOMSKgE;$?;-$K$sh1< z11<4w48uvB`SKPTotnRcH{`*uqh#8pqx2dV=*=STB@^eD&Q9_#APv&pWYSTNGoc5% zTK*s|ZOdZKbh$Tx&w&8E4DFfDD^I1txu&3;Z!r9u1L(P4rt|XY55R{4@a+NkV*&W?0DNBn z?qEIWCuey8US)8vp5p=Zt9@{`%m1cTf@uQ@LF~IH*qIXFzA|Xh<)g@2FH?8 zIkvZ*20M!gXm2s~vd7?f^;F?YfivIz2Iog7*yqBCSTHlH@||HAZ`Eqrq+6w*lDn5nCFt2J44|PX0VgZoAG;1{Fhn(uvOv6MIjQfU8y=W2I)^sKwB2sqvH{# z{81DCF~%>N*&O*0qT!{Smzuc$h97aO!#~&ZuQd3#G~OKk`pgpM)ot)EXj%4$A|ooV z{oqZ0TTJ}l*mTep_4+Y`Qv^EFK4!3|2`rjf6@F6F?l$;|hL~k|$zU&O+UD?e)IhW1 z;~YZ}M?(Bz{oFdUglWnR{tk=x=j*N3#HoD^w7EXh=9U@!e5L_TdrtY=4NktqDgOqG zQBKwy9>ljAyaImGt}(nH4#0OBd?D!}|62|H9)qhf8+=-UGynYtS9WE|%&n2ggJDFe zyl0qAI@xC^e}%z~2~o6-25Z;(Hir|s?pqD6_I4l-#|)OM)8MjC6CX7A3FHGgR~Y&S z4IU*9KJ3?&Wrx8{^(x-a8ti+Twl(~1-4ot$) zI+I`2;F3qJ!OkWvc%aEB-7YiuyETvIFd7Ei8TH?fG=u50UQ4fH;LR<~8KuOPzhdU^V26Hh{XKsaAX*GjVF(%KpDh_uJhDzfRl3!EofN z2(nOlylmo(3KSn_I+?@v@CWuS{C3m-4Zcm|(J&ga9Vco{oNUW1!!m=*wz=A1$^@aj z3n2{aXM@4L^NFnnUu^Sj*8S>XgR2Ml#0{UF2ERwsH*+eg>fhjM-VFNdL9gsR=t6h4 zE>pAJV;^FwGwhs~@yhPt`F8x9f`8D->M%OPf|;vT`bEUxIG&mK*6{lo->Txn}!o+vy<6FaP_4w6n;^O(Z zXog9nn)m9%#1ESIWg`80k$$_0@5;wlZDf3v=D+hW@q0}?=P5=vv}3M(jxpfu&o=(e zB0g&3gUZ)z;=hS}Z2HLKs(PZ-?P?PrRK7tI|Gs?wRpCXtfK0#L#9x)?zdCY(;=h{l zI}Z^bMf_e9|10=S{qG2czt0TRvVk;HjN#gW4ryri9vk`n6cX))p<+IjhGxj}VH-gk zU>jk)o$G!xAKw~&obj#N)|>SIkdJQ;e^%R-%>i+${3dQBpROu=75P@FeC=G9a}uNf z)nN>b?3?TxdrkUF^YK+^Uuqrs2NU<5yl&AiZ)9RTe=xJ81y7{ozjxv1Z|FIM`zHu7 z#refSKaz-JG82)vTF1|a0Z}*~7b!UL z)dV-af&6(=sU7V(-FCbX9Cp5_1Nc?z0z8`UyGjP+;)A=_37UM)C zLeC7-J)`*V*8%*x4F>*5!OoN>*`peU+_7?#f?kc>c>uouSwi{qIL9EmAIvBIgbv_W ztzAqg;;7r!ZX7-9Hu1L@CmJa@*(8F`_+tSX7I?dcoxytIFPKo_-djZVb~j3SBKPt^ zD^Tnr1v`d1e%p2Af0|3oF_W$o6Zf#Az6Ywq>KF5~Yl&Ow%)W++i#TT(U;dFDq`w#^ z8d27azP$U&Lh`2-)b1zd`QclM-)LdCWnc3~;xQM$$n}xGnwbm4ah7vq(+4LSan9kR z$VCcHv~_eu)z@s7w%hfQ&v_!Gy=zn#Eyfueac5ikWZ%X`jIhAfywOBQob&U9#&_v0 zq|f7(f+FHvAVS)EiqdBuFQmV)i2nB|U|8Iov3h>nYU1cL#+_n4ct)H#BBZ@%qUnSM zE^~@FuPdT|RMF>go9~BKl23gi zQYa6My2qvOCs2$NjTD?H5W$_UHu7)M0sJmAz6;k7U+&^^B#!7+z8go+*M|7}dj1r- zh;wNXcIEBlpU1ll#Yr#?VdwWQ{kvTs<-c5nwD-cd^I41&jW}2EQQ+=bOa6J>=6gew z_#a)`!h9FFxb=MDKKhDrq7es;+T&hIkbfSxHTr_;M^LtLwtnx&&=PhITG)A~>mz@3 z4v&j(CQytMjX21}1M&*RqqJd2H;u*1_T?F#jfw^ufPzM_u=(C7P+r9bOj^8dLF ziWvjtv|4eaovT&9c-aL`aDAlDw@(2BUm=6aA6Irbj~Ci2OddV%U44`@kK20bJBRoW zUFz*&*Nc3w%Lk0_(O%N$am(kvvx!Gty>U%qUBtQGdfn$cdPtwgEuYmWNZ8ry(jDje zsGt0}FJRy|$)NIwljM`fUH%os|HGwkcYWl4iwNbt5eLTJ<7VGOpcp3_ao)s7f%^tT z4-4GN^OIG?2V7dNrLBvI@xo%4qf3Mx+snP= zr#Akpr;>i34Hn$hi@2wmZnTpcqQ5f_2wz0VZ{hP5|NpWfI7l zHIvT<8+6An0ug7ui(Ajg1N0T+L?eO^!`4NdJFVA!wvW(Tj1!GGZ|0-G&ApLw=5brz zamS%%`KYU1B; z>7g#yMf42V2<3uB3UxHz}AEx}TFroYo*C$^`{6j8&mGP}t^wZsZ?fNJ(o%E$HZu{xd zCgOG%UfgwMzp0v-yDWJ$@|tZ7olT@1^zcS0?&x^ln80>8UEOikVjs zw;}A)8b)7McI-PYAFIJ7s-66si~ob`qnvp@0tWt78C3p7vzTule@Re8+*8s#_%qe7 zpLKzx@r6str`kyEIQO^|l;=4S(%$RUc$dfDAt)lwE)mk+{wng%<7fu1i->X41>uQ^ z`BEWndz(Z_zq=4X=#9gKigBV5C$E2ot&2GSZoTgF$+y#6j1!GG`TAwpx`^nf7UMG_ z>_Z`*?-zym3o@wu4>wSsGu`@v`Ep%Etm|D6o`|zo23>!|`LXM_p8HQC|2z)!tK7~D ziO+TO#Mxgb0dbqhlx`1IW=tjFKE;MvBv`()y?+*-ZHr68Z*i%{Mh5&MmKl=KH> z(Dg^ezMKofgY#lH-$FcpE?kJ`=cR>se%@P%=jWe=cz!-yi09A03h{iuE5!5j*g`zN zz9_`)C?QemA+LWKgLA_G{n$}OQj-tg;w^OD$FqL(xayvU1m|}}^v$ZBDxVIF&{8 zSIwl}@;IhRu8WBEnhU}caV{?6bJG2!D8`B6+^UFP_~ik3a{%5FfG-Td7YE==0`R2)_!|OnyUSuVte+8Q zSpfZt0NlPs;pKBp0R43V_{sqMh5-D=06f2rlM+Ron*-?I7=R}N@MHkq7J#P$@U;PW zR{(x{0NxXT_XXhn0r(vOcsc;j1mM{Kd>{Z{7l5x1z&8Zo8w2n=190_6J!KP7{)qF| z0Q$iI{GI@OQviNn0RHv>d^i9f3Bb1m;O{8HF-`Wlz2rDQJ;om&e5*fxb^!gn0RD5r z{(QOu_|PJi71fmE{CEM@u;FvunDdP9>?!{Ae+b|+W2!&>djt3+0^~pGZht<2edS*; z=#PIS!gA&J{oZTJ^`ihjtpWA&{{rw+1Mn3A<^3V#@zeh=0`$Ng5G)O#dAKQjn4ZN7Ftfa_S^{j~VU4L$WB^R?$-#Q%tYmVQnE z{iwjX8*k|=1kPEj#j6A^^Ia)$Y?Zn6ZxOiUb250+QO>D?|AhiC;4g5={}w6?j(lYO zmMc`_=L`O{=X9i({I64}K41B`ptqKp^qhrSJ>Zx{IpW;=wD_AetZpKILeR^4mvY`` z=)HU%6ns7@_ypDa8HS$f=oa+n3O)}D9NVSJF<;7H<$s@sm42=k_@@m{{+z8@db^)W zyjS3Mhnem6Qv&}#h7WOBE{+RyltGC1jFy~upIin07IgzjI?;AzF zHHMz@OZ;+!d&?Ua^s>CNzjJkL%ezp}OZ=7q{wYBp#D75me~FKmKgU@*>fv4ZXXUr! zGjXo&EnbZ-Up_UfLyzw}6I@?P@#zS^1Oj(|K`@ zO?1Q;;h&}dTY_+2oO#iC@vmvP2;U>{74TdBzYV~rLFaVje+B+o`qu^E^94>DV(IM; z82L!NGl2f>0r-0Y@Gl17I}PsbUtbsWvVY0(=<}el^2>U^UX)ALyTr#k9?AAOPvmR) zP;aY6zMJ5u^WyK+u)<}zZ2R%*LFRjl$hXn(Vf^FxXZ2vmQ{vp6vpCxl9dYi?S^Nft z>UlU{BDJ{MZbuyD--mw||FWSc{x1SQ1!?KXpSy*Y-kz|K{&ayaHuS`$9uQQHd>+9+ z&iUv_M`!U*>7e`GiN$*jZpUAX+ww@geFd~u&R0@zUl;iv4ZZR9h@akw`{|9ipWcXD zy>ZN?V?8{Fe^zfd6NDqqHH*c6LJ-c|p7(25(aU<5;}_Qymd_$VFV}JG%XF+??kZb) zjwy7^SFYcuDpbqKHI${FF7V}o&rE}pKX<1s{YirUG=WS0-0ipYrwcv<0fpEZ zO#)vc=%t>Q3jCLXzEbe9Cx(=NouIFWpN{o)9{$;US#~<&Tky}~EIS?b`4586-wOH{ z5ohVSrlTXDD+T?x1TN+8qkwSUeBUbYVZ>YhpAa}t*)0AwgHz6b6!?z?{f`BoKMFi9 z=$Ax51V?$Koa+S6y0-b=CvfgLTl^ml&TM%4VDYaA`dbD5g1{x8V<`w6A5zY11TN*w z2%LG_d>;|`&jkJ>flED~YZ^48CI0#VJY{g|U#?T$A?Rhh`k=t29r(1sX~V4i$HGs? zGW`huEPet(ILgVsU~#!$@rcm#xg>@o{cilT^tTd(BY&O>S-eA`#r)F-XL%+6HyfOM zxEF2t+-GpFoLn2ydHLA!i1gC#SvkG*fobW00j^;K5mLx;6Tt+5=DPEd6Z;CmrWD7T+r9CI9z&aLa$5!0#1&Y7NfvJ}Pkf z>6ku;e>UG=8=QKRdicG-r5-LKA)J>FznkudzfQwS51WKMRR$-Y^8~(D&~FxacL4nZ z0+;>$eFDE1ep}uz2H-mlPQ6L{^L0Tl+w(h&9{9LV$oX!epK}GyzD(z(XIrJC9whz$ z2>Q1PJ_iK7oDZL-xM)7t2>N<~w+ftnoR0Fy_I8az^ElVGo{;k@+22KUle zXmZ7;R+QKFQ_`2Ky`|Gl6; zTi{lH_KPnGT=r90kG+CFah8wH_B+aXzu@yvDprqQj|u!_fj=(rsKB2Pc%8w?pSKjO z-ngehM>)6`VezXKs&V=)zS7_`Cpw-}uKw+Q@xLI0${xdx#l{R0BG_K^G~{fQ)o zBmFxB{W%KN`jPZgnd(6L;9Bj{%tob+jt9{qa2>ob^v9*HdzScn=xCkxt^<1b#Q-tej@L zt>{|>F6C?&a@u(m`OALShAQfN=Oz-v5&t#*S$Sp?grm6c5%hK*NP0Qn zu=~K&GxriLpW_gw^Wv7Cd>+9+OE1r5?iBbO_~}UhwBUcKLNz{J;BsEdwYKFi*9Gqt zxV1mz{}cSP^c-vGh@U9vrG5CS!0r5jdV8P1<+%Kyz@^=IM&PF~Q#j_kLf{J(s_W(b z0+;KQlLci1x$&PsjKMfs>Ao_@D64<~x@l9Me53 z=w*NTXMuAMkdFMM-Hv*2E1%tGBmD;jpKA?0>CP2+pTNrm{zFp$KK@SNFBzQjKPT|t z3%o#n(9tp9|H413hwBK!5uYRY+@Vli-fn@fH#qZsSm5^0uYB!AYm zEpMsd|2+H_xAO|}mvS~3dY0>X!N<<$N&h7NSw3>y;Tp=~-!yzE*M|lEe+=$zKP(F! z=~IIK^9EFl^^amLZv`HaN} z4Q|Ix${E!D$-|bbS8>zjng_qdtsNu(F9>=&pCtcJ3*4}AD8_C4@i;^Sw8PPLbzb9yq141fPawuL3l%? zFbB(jC;PhO zsa~n#MuFS45+S?KK-yYE!$-IA6K^tpJ_e1S`0I_Ij|YvP_+`e=#}4BsZs($ez9H}y zrbHVb?};GCP$k>1W3 z7=A?H8wnWq4S{oAX5C8yzsrJn{z2ee=UO+58N%^#ukz`iiv=$2?IMA5USZv>0^edm zJhK9~Yk5X*5%{|V{l^5(eQ)cY6u500jQftj-*1WWd|BWh6uA8kIHNx#@H0(6Cca(Z zwF3W$z?TVJ+VXaR|AU~vOW@B5{6T@s@nVO-zb@#X75Kjh+@6zCemPG4O3?pM(3csz zLi!g4UMcV&3;a@n|4iVm0=IpQ(d_~s74(Aw&k6iNfpflL-422O(t>zCD{!ultb0k| z|7$@!rx+u}^8P{K6$1aGz?%imb&z$d1a55z!uu|eJn5cNvu0l;9RF$*C=qV$E|A>xLwmRu2bNr3;MeR&i#4owh5f; zIO{$uaPD7O_pHDx1pb1+&lmWw1b(5w%Z}9?sfS8|R|=f>)~vf!;Fkz|rNFBM-YxK$ zz&8uLTHqfLc#Xgx5qPb@cL}^s;QX5fbSxLw@7DcF;7tObQKBhGFXw?30>4zyHwv8V zQ|np<-YoELfiDpFkic66{xN|s6!=boFB13*0$(ifUkUt5fzRNA36AAmBJild;{vZ0 z_)>w#1^xzsuNFA>tF6ll+^)SCw?*J~uY>q@fpfphx+ev`Lg3#Kc&oto3!LYP)=inN zebj^9OCfTm!0jA{c%#5?5PVt%extzK1%8Xb?-KZ}0^cTZxi0&xz~#E^S%G&7J}(Ho zOW+3u{&j)RG8^C2gK07>u5*^a?OK?4t-xmzFzzye+dUaZtrWQ3izfa?f!j3#@vOj4 zCt%!L1b(Ik@!TqKdq%_P_X&KqpnpW*=L!7F0>41uy9Hh;@b3%!VuAlk;9T!p_eX(O zTM*A#M!D2~oxsl$xZT5Hbd|tY2>K?0+r2nOw+j4vL4T9LZxVRF!0nzqqc;ltO@e-_ zz*h_WeFAS2_zr=m1pc_dI|aU5;I|3<`vUJ3_dS@*cW zcUln7-2(rrz`rl>X9fN%f$tXh9|it`z{`yhq5gj&@Yw?YslXcr{%e6R5cmOsuM+qf z!tNynex|^)0zX^eZxQ%80^cfdxzF}KfnO}>cL;oyz#kX*O#P+;f(wV+q{EhhRMyDs$)7HPyNnCsN zy5*U~)mi=D!c6so8?)DER&3m`wlz_c$+p*IHf9?7(|v8JOs00Aw`)UvTVGF4U+>k~ zmfA$aLim%t?KQor_0GI(YD3mAyd4~id09S4Tj!>&?;FVW59ox4N>rCz)0ehneuULs zeRUAHtFJzh=*M5R&N}lbY6>2fl1XZ>zV?OY^o%K;=HODV%5)iJ`s7vvo;AG#JvCiD{kK60O^Jp@U77ywuQJwMB29*@GnMvj4Evm#+@4ALWVqG$w=*u|s)~5Rg`p2Y{ zdaabM-b7n>D%opNt;*E6Eyk}Vg4#^1rY)oX-gqLJPA502W)Q1hc%8x)Xo&65ukd~S zncDVrUw`c){?MnIa1Y9nNTt)geTnYAwq&-euh(<{w#Ip^_=TC8_3M&XU%z2_X5F$x ztZ~giS9g0N)0Xb)&n8qL)};QXcA}@Nw=bPAe_0!9YHLfRHngQcmQAkdPEC%R>XqXV zs$GPQZJnvM+nss6ec9B!C0AcR7Yq?*O{~hqlwD|R?`vPM zqUp-3>JoLyZq!>tI=LQtZ(FLGME$kN^*5y2Iu~1;RFrp3acfwZxvjnG=6F@@wbeJ* zBlZcO#A#Zt#LD~IdZuz&s6*8W{apBeM}c|X>V^T9jj_jDJ>+kslZY!&ma}ZW**l zb?T4an+mYjqIg80rO-xOjV-$RugkWhN3-%<)+A`vZ%lV(Q-)((cV8yuB(B#+`r1@x zpeJQ|JVv;VbP_?+*474V>{{KlS{f6y$92fAFCD91+M8WW-&N=^$hxv#uXWDl+hviUw}c@ zR;F%gZ+mKks;(Onb!jLh)pRX?uN>&^UaV=xB4?q249K>&tuvW!=t*Te``RRpl zUwbMsknL!MLe`Hzo8_51p>Um9ky(nx+WNY?Q*9hoGIeXR=~SwwC)uy9R3lnT-%@lp zo7`(sYw}7h)TfU&>-+kdN&O1-Xo=M)7B7Zf*ocWqq89_%_zSiwvq}}rCo@dz(zWPO zX-sSxY%|st_1~0GvGv(>vMZZuBxN$&mu_TX>8YKVM9VWBW0WXXqeOy&)fm*a zc+5Gvb>p+24J#|(4)s$QOH8&?}@>`kugS_^Bh z^&hKQmb`Q0vQ)A|^%~urFvF|uMbobDNv3a4rRx^4DIATKwp6t=3=UZs&%WLX6@Sg2 zd-1c$+c8j1toW<2dcxwu-$-&KRHIju8V|WlxP~@Wtwy|U6GL6Bp*_`+9O%v_T>6Fu z?a6+01YP-Pwl`p1RxoPT>j4uT7)!EP^>zKJHkIdhc-ve6>gVmSldqk*~1vXTB;^A86^^>4r8ybt}^wuf(8kryfcSsEiFqOg;{` zf+?CukdKe)FBm@C8nb|sJPXuPy*)rope&bqUmx+c&x#Z=2%?8Wt| z4QQxi62x~|w*1wTr*Ph~JX5O%o(%PHNRM#Xq=fddIh6TXJ#O=L(>w7r~ z8%vXOhr03P*r+CuW6YtjV5>3<0t&{dsj1`!?ir=c%%On|`?_p;GMSFYQtD<`WH0L{ zYkd&YEh4md^LG=DYJ+}`#)8;Xj=32;nm0BkI_E|QdhJLk>?QK&=vY}On zVl`cuawjsWWEy)6W9lS@xxR{HS*%JA=v=-`L|uX7Nxr+KSYuq#w)ONkwPF*eE7`sH zj_TAM6UmQLb)WIWtW((2>JnzPa&1a29WY}(A~`n%78T{H!_H}U=2{dL*-g5T+>O%B z+-E9gHU(<2Do7{RbY=UoiQyX!R z)omtwJPpD*7(%{%qHtf$S|4v8^V_LgY-+KkI-NK|J!|8*6!r@4G-|!H#8ytUr2bRi z%wKMSz1_hKYh&pMwqC0fjV&v&qr5!XKc03uwwB%!tHHjWt=DTNTCbjBX2v)+EMmq! zvqwFps#9kyBxzY@`+MzmDRtx$>q^vKx3RZv@v?-Ke%UK6ywb3*{Imm&9Rt1UK%nl* z-sJ0&h`^n8Ugl|_?MP;AYlG{oeEDyoU)Riw2T!vO||iu_f9i707IBkd8_ zPVMBhgXD$UslL8QJJm-(J3Pyun00SVxCdu?cn`1@K4UJFjpalWhx~%Ygtu$?4d=R| zVz(s5^L9b8h_itUZlVI=+CB`+HjPZO~BVWfc=b2 z0=Kc&VjYsm_KiQIAlt(8f$9!@U8}lAUj|5KGAZ2a=x9$M)4u*x?^v>PC!Rq?a;=!I z8u#?PN$oS@7FN$#vMC&9Zqmh~3X5uXJX`nJQXNa?zH^sXSO%qlbf3Dj$IA<^M)vu- zp6JXTJ3{%HJudjknLXm<%s#N?PU6gb5qok=tQ)s8c$m5(ZO@MlT)c*uaPye(;^!|? zMQ+PkmAOi7b`zih0E=?V_nrtn-*|h$6$Y6zXPcJ+8Rpn!nOa{W9gR)>Fhx*PSUn2d1u$yqYH0hiVG?(2=UTRT+Iu z;j=>`PS5S=xJHeR`d($7x*>L(J94f`NJAhkKnfLoRD}0!akZT|nXQfu^rlluoN!`T zo_tNZV>B;Vsqv@C;>e?7)wb|50nS&iGY7;9YP6xo&g4dQ6@4tjft$H+abr~|F5WQ7 zbhTx$pu^h)IIr#;3*W+Ce1u9CtKx;s1TKHKO-P70jD6CkE5f@h^GpQPE)10Ko!8ai zu2cOQTo4|cm~I`c%8XM&zsZWB$C)aRsR@v%rgEl9tnqYSFqAPbO56JqwG-p0a96$LN19RB20 zs3zb=8dnsorS)E2kSKUnq&HQ!Q2i?Z_*;&;G30lYQMip5cm<{xZ}c3#(1mq?RY%Z$ z;)3g&MQ=Yew)bHm!c~ig^gwU6t7rUqU&pPdg-e-#@p6x^h*jlZ5gS-sWu_=T-K5~8 zr2fj@6}@e#fyGU{=9uV%zkZ#1qi+1lS)S=vJ%$2~<+M%SwO6KA->T2=Q{7y+Pj&Or z-=|uk_o>$V?^CVUXWi`0{A*N&+s-L7-fm5T=^UNz&dlUJt6j72WrRs3s5 zJhz6HXHw&8#qQ;aSAJLZ4&7QCi(4y(<2Ibyj-_4-4csJN1X7zCW6admcEI>hU`(sM zuae~#Fyk$tF`vb&6(0YnD=cff2h4sRMf0<@V|;_U@a5^nW0BOSi@df4mThNzk`@nP zQmr+3Cq7nXK47r8ciot@S0qeOhfrTD(Y~>BLKB2{#(H zdSGH%7Z_f~q#EOq5y7YU-bI4{ds({cswr{6`&g6H zz$+<*j}pdczKScsJra(2u1>34(-Up#V-n{O!kAk&W2usfD4<{2j+J?nfT%JKZ!OEp47_Cu6A)f2;Xd4eEi`ZcC#r zQ#W`2YRU?C1Y2T<8Vrt{0*fr!B)-K^pYZ<_g&kav_64pt@KY2=PBlTpXr7Vp2w4R^WwH7=2Y+xM7;7Rq*% zB%4@G`3qDnK2Tu4O)!xfJJkLhzBSA*{f)(_9Lb`w72x9wssS`!+0~s|l*)`v3Uh`R zbnYWB;#Ko)D!dq8^g*RHV=C`qa;hmf0kN+@@;d(E-wB=EJAK8k^oe(5$5NByD;XOn ziY4DoHIV1c!d3Yf3&$eG_}de&F!*0Y8QXXAmIi(IX-w^5V)9aT6MO;q6_v{01la+j z@P&?8tmuu7qb+N}*H9^|{%`D7+?=i)O*wrg0{M5OgXDD&$Ls;jm?WI=0BCLw7R^|W zmY@^tG>23rJ~T5XB~2{v3BSjDmEU{JW<2n`eLk5tmSIi`Is-pPqOL8sT;n-8I*Lm2 zoAlc6nY$lre}(hsoo4;r_AzCA#JkWq^PgDj^izDk?_Sq=G<${`$_m7DrPsA4BHpC-^keD=dI*sAJ9fsP&Yw3NXP&;iLO}S5aT28Pl{j zwk|)f9aBvmaeciaQjcHB*ByQ`@#-#%u6?B{?k`Rg<8KVF^|o-$E7cbM(iAblrr@{3 z=B{;`)Hezi;SZ7>UR&6FSe$SdM`JR;6BM~<5^;FpxD+{xB2E08idV@muUu7lDG71% z;NE;wvAW9Vn~DwcYl=ns8Q=8pDC$Y=2KWs3zWeaWe)oG*<0*h@3`HtviQS1i3a_D} zP529fuR_s$YRoe^)=FRE^Q}3E`^s;$*5C_Z_G5%cwbFewV`uHJHI>wlS0zV$di)oo znWAxv_NjP_(;Y^=_vvG}@Tva#6~9n^_`yqU>)Vm_>V^8R@!@-V!0|=E@qW>~YD`8j zsPg~vN~$W;{eqi1ulfruq&!ZVOcAe@4U+9~IX-z`&>m~~J-k=1p&d~Hk8t}q^0F0I zYtd$!@BAu%&%sWaUPEtK=ikFvZ{EXL+OjIKv?bP=Xo(Hr-wL!i{=war*dbrXR`0|w z&-9S8HsQW|(sE#0=}Vy*k3*7sOCg4y*{$>qhFY5T7Ts=>2+;)@)M%?OXcc? z>9$y{eRbNra1zX%e_O7)T77m<{CQmcIh5d3`M=JKR~PZ&6lWe@W5kz9@Z8wL=aPjB z3;qOnleyunzSN6GKQG-kZ{D&bKDIZnwmMl;+uqU9(A3e`)ZWnE*ih5d&`{M;yQZeC zsyWiVehDlVKN<7Y*C>9}BGZ`5s zmAHB`Ime2NQa=f0*4G!+R~`9{Q@@iDp{7`b>bwXr6R~_~tnFVVNp&aZO~`l_tE32N zs4If%>c*|N2_&gyE)y+TO|jI~)GiN$8-SiO1Kx>n4+5ZEaCYsh$i! zm3Vye$BIR$DH5uwW)ezmDCSpR4An7<@rjNk-g!}NF%$zfiC$66uzE85V&gBSj|4^K zZmOO{85@{*e5$V>f0DZDBB^7wlM!1b)?#BIs3~S&J$@lol8IW-@n_c%n?$wM$BGnM zM}Fh0NtI-xC9Xw&W1Mx?6iZt@nbIS%xAVs)!w-pz_*G4cp<5MLti@24ufOup>Cg4hmwCdiNKc;4 z9(>Ps4U`-Fy%3V+EHPn=|N18X{QC{vX7jWUZ~ps{-Y@_C+jWY#Nyz-C!tc%hm&^V0 zS4su_+f91=@aF%YNbi?_F|5yZ@tD}{$-{>-u%4sS1$FJf3->fyTizT9@6{C zf56Bu^?$sN{7c^8FaNEkQ_k|_@0EZ3RsQn#oAeKP(tGuhMtVQ_n?IrxO8J@AtN+g~ z^Ot{y5!9QXSN_ig^#7rS^E`Uyk(T+3cZnKB@t!|5E^Y_5X;;-=ijH3OhTTH$SiZQ?K)vKWfr{ zO0#i)z5E=c_me+vuV{2#vFKmRI|fA?YX{{Yha<$u8B9~b%0_R0SjCV#K~K49|qmX~=+_l=wV<=zzh_9=hNC;!1a{L9~J^0)TGis9A&n~~nH{QFG)a{RCH z$^RW`oxj)qJZSQ_{m16dyrlbj%3uB+W0e1&k={@K)t}J0NcrmldHc^59scqk7^D0j zUgIx+*$kckGFe#oz54$E()-E3$4CAqANfyi^OyftlmFrL-*B71{DUTa*J0$ZMtVQ_ zTR*82t~Tk}e=ddJtN+^D{pEkznN;68`$%Zqi?+N|FEZwx24b_mjWuVVy>{{}vzlzismO_TQIH{)cNn z?>FtQNdGf+{+x45=av6&k={@KAtS%dpY3Nc7M`cOD8PR1_Q{`Z(VPG50Q=W( z@}H)Q?f!bpe`bLF+vk)2a-aNLy8ZP(X!7^=KX3k*A-$jeolj|zt)AKbu+>vk{*gZa z{I`!$|8GNjzx>O6^2b(9QT{u6{qx^3#{55n^nUqQ`Q(qKW>Nk_J^uOc9Ao~Qklru< zxKI9A>J;Vw(5?RY?>70HVFW4veV;SsffU>VTDDk!5OY$?hJD2q}Y3&d%*gDp~6ETKe6Gd8v*#^{<5 zT_d)A&z|p$cfNT~?mY=}&fK~0ectao-#PcrfZC?#e>3!qpC$ZRxP-3%V_D8GMU4Lo z=o!C}@bOb=6@MHyb(Ch+_n%JTXYg6D>G(%O&-hKkZxpeqe|*)e;y)++9zh7ksebl= zTR-k&!wR;YNGkK66}`*1(`f&%L(ly8de$l&-}uK@jVk}Urf_~PBL5BO89zbzS;9Yv zn;MHhACbQwdd9CK{F@1X#kHK@6p?=)^o*Y&`~`#`zmD@;gr9~c*!2AOfu8XjgdeP5 zJnB`={|4df`aL3iS3k~!VFg>=6z<Gm63O2pfZg?+uN*c7RzGn`lsn&f z*7>^~ddBY}d_1aG@uz!!KT1U8Uk*Lv$Dgx~U7Tk9;#N_`AME@6Eh2xQ=l8c-!v87Z zA4u{1ry}zALC@k(5k9`ESH(ZovwviSANKr{^6Ve=guj^ZC)e}%bHd+iiZjsUaQVn2 z=vn+Z;dhDN%>PoTTs^zG*e`s&ep3T1Vo8}~2Zj}FcVEi=7e#+n@MMg&|0U2f{|zr# zVWa4ce>2wp&rDgq_TTpuD|B(nWx82zZ2A$N`)5-0j^{Mm|Jk1VXG#1!zVUw#F5<4| zj*o2M`AZ4^7Y;1F=AR2a%U_r9v!XNn`!Lr0T5O8=D5mpwSopgB_59DfjQfwBYQc^t zHmc@-4)n}_{fk!N@?`wyamZcI9e-gl=f{OV-2@kCI)AOuGk#9^heT`mD=^mlWmr@O zq4PKAJ65Q9I)6){XZ-X_R-d(9%s^{2h+Szo?PtuT%6#h3`5Y&jrvk z|0UvoJ@Nnc-8_FE4zk3s^?Tr6?mt#z_4s+KqG|v8plAM5zq7Kes7?Jog0ZgOyFL7@ z@MRm4tC0JLhuN%Us~#TaXS7J8Py4&whY z=ym?aUd;Ig;r9vQpAS9b$6mHVxlH%^*-ZF7kLUcN2n#1x8qo3gfS&PdgrCOerkVO_ zMz7<)Lil?9=FaSv#WJ=lzv}ti;`1;1C(24Y(EgwB_|N{qp5G~Yok8b^elgwKUzgvgogi1j5Yu1?{R*o@TWVl^qM~h2Uz{55>~J#L_Z69=D(Tv zZ^5;+|B~?a{I@48xUcQXuX_H^@x)&e{S9TM9q9O{5BJ1>mOcMesb6;)t#3S+>$AhG z;yEXjUh5Y_&+=Cy`TGs7q4W1uKhBTix~8>szkc(g`JY42__f=trctD({>)zqY5vmw zoL>-r+c)U$k>=;1XZ#l7HwE~=CH((*_&Ln0X}xV%e%1V~J-Pq9=-&*P{}-WW{*w}T ztB_6pp2t|{Z+tE1XGU7#-m-H$(Ei6j&-jOge@OI(Zyt#?f5vw$U$4Ie)|F{PY*&6Y ztYEtp3lx{Gx__obzg=Be`@b1_=D+ZYr6dJr{F_^<_TSKl^JC{*;eU;}K-2vB&@+BX z_*u~!{#J}N|HKYtiYnm-I1 zgqb4!{Fl7Q3V$wo*Xj6&LeJta68@{Wh~`g0oQ&TgeDhhbY5sOxkm*aJe?2J1SnJ!M zXZ{n$ELw2>Uc-Cszt^RlUl`vlj}1@(X#OO8&u9KS>a6}l+m&CneggE&e>3s_2Ckz0 ze}MZG;}<7bb#2;4+m&B6|4Qf?zeV^- zQ5ybEj5Yr@oIv9jzHCEY{zB*(zk~4G3IBah{IN?cQXV6`{DYqOV>_%Q82@g{9E7Uy03byT|xc`Lc!~C~F&-@pO|NQ`K{}-NT`P%=1i2k=>owNLRioV8t7-%{l zFGJ7#C(LGv7TiA%;Jx;U6OW&7Syk!k=vImtS@Ko1ka>F2aA0@R#8}$?{(m{(v&s4m5u$^o(Cy zwCB71!{q-k4(a?a8p`=4;kT&5*8By~Gk!|=!TS3L;m;nz`T5JNe>@&nG|j&rdd6=n zT3Imu5)SG3-$a60{+ooq&R(Ycs`)#hXZ(($m83;!^8ZhaHNO!*P#V5Ub8N^DtWfg| zE7+zXK;}OwdVO7ez1hRKJ|+4EK^Vqbe?9cZQ%apz^SV8dl;bA;e`BoUe{~S&7lbd5 zapkLP{x;|tKTY^Y2!GJooZl&YOixAA`~lE2exvY%_4g6srw4O>TsEHm74+)-PllfH zn+X47!XN6%e^U5~5dP_&{I`<)e?s`%>v;Sb;U5>mf6DXxo)!JyL-d+fUe+5BhTu!`XNJBmYk{oUP<`_Bnq z9s?b|YW**Pp7Ha-Pm9vz?{mWMypZz?!Vk;e?+0>yQS@h)o!f!V-%HRl|Lx)*>FsXD z|Cbo){GEIj=a+>4sOVj%`MscL{F3m4_4gIwXA+#BpK6I={OQm$e*K$PALM_HLpuId zr*VGba;tyDcI8(c{{zr7eh1#d}vX+DK9|w?*f1HP( znP&B2{LvnMk?@Zv{289-mrmi2ur|uCI{#OAo?q(Twvu4|pGf$_$MO6pWZ~adCfkAL zp9MY3e@6I8(VO+xlknR;{3hYM`Excj|7V{4Cw_%hWSmg?D*Z?ve^&Izhv<8c=lY!J zvAtF_%|8KpmcKm7Utf~HXFUA8@bT9R6;1QM?#=x-iQcUjcM+}cf}Z&=koxOK{2x1s z^IL=;mVf-8ANl?v`mxTq^xFUV&@=z-#J~A1(&fXgtKIl_GB{kde{~A~nh^fm&@+CC z@J}WDrx76IXRfs1nJR%g{w>fmewXlz;@_;_frP(n4CfbqXoW6ca+#{gw-5YoTZU)8fBT^d^4^ z;(sw75RJzkic=6ieqN|(h81jGctAFK{ruk{`V)gEW31z!jNk8=z9jm-A^J(sv-pc- z{)UnG`}_7E;ZF_W_x0>QhlDS;;qJfx8cz6Azs=)MWh@xmdqvavOF_@#PqbTEaQ)O0 ze#1o0&j=qMS2WF^j}sWbPWVYtoBA6`_+JTM*WY^K>-pF9w{1B0-y-@0U z<>NM7(Pjz1Mr~lmt7)!(2RB$VhK-7rtG936`g0%las%miLeKms#lO=V|Kst;>D}?| z4|Dz+;k)=9*6H2*Hs~2YE&QyUX!sKd|FpH1uj{`}_%}F^^tePtyWk=2KmH?onv2tk zwEt1iGykn5e-nxSr&n=)t?Bey?{b^Jn-e z!tasi^Jo6MF4JmlSANAMD%#48+<$XK|Mx@B{3nS2$;AIM&-yDwu;o|{#!+#a3JY*{?7H(f1c#;8j`;UC4bs~yYNRUzK;J=sb78n z>=J$0{(mv_Z2p^x|LchVou2xS-)N0b3-N!Pr+({2ALjqq3huu^{Ldo($K`qb=7g{3 z+vLhLcl;F3{@obS|F=E+cRTSvoA|#`_7A=OnuPCL|L*vvHGKbQiRgbl^sIhL#Q#mi z|23Y;#*go@b>iyRJU`4M{I^iQI9&Dodn6+N zHBbH568?O`pRkbkv6AQe>%yC@ zD(wD0M(#hF|GelA2H_ZM{x+#!tzR+U3f~LS$368oXMq*IAEHmKu%7h#ty^e?ZvS_e zanB9z{Uy+|_1i|)&oTt>^zOPdJ^N=SB7cTw|Lh?AdkKG(Xa8&ve!>MRz4Px5kMQiD zM+pCZ!hgY2e`|!_&w-@Z_49yd|7(tzzh$2NFZNdpbn|5H|I3O04YGgf`YA-@uYsP` zPYvO(B>Z)GUcc=T`42(Q_({Tlknk7C{-@(FMdZ(gp7GO!znbs|Zs75EMdbH~p7FDU zzn1Wi%KcyOU-4UP{S2{P`4yL_XiKI3b^el~clGB)di~{~XZ{kokH33=iuk)qf8*TTs zl)jbt-$eZP!7+1n_jgYeG_9>mz3Yntm<1MbH56Z`tO=!3`1SfBeP5`belWk1#| z`ncLab^J%6H}R*;#RIJ<@wdwtO|$T)X9uV$Oew8{Lr|Blo6eun7(55F8k AnE(I) diff --git a/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.cb5aizg7jyp8yonp8exnihyxb.rcgu.o b/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.cb5aizg7jyp8yonp8exnihyxb.rcgu.o deleted file mode 100644 index b225a90f9e9c96c505da96d847437371f4c7f201..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2560 zcmbVNF>ljQ5Poh-TOdNEVnBky3la=5Nt!kZ1BwI`Bv1vFkUAh4$9W+si5=N)NU{(% zMs~!)#Dw}2kPs4ofEhN#gcy(*kjkC?E`D)e64H~bclY_b_uZZS?B}=cZ7paTBh%P* z7Dt3K_TfbAS3Esp~tO0km@@Y6@|4%D+I>5|fGoYle+I!lLqVzE(W zHY@|X%C8e#I&yd&XQ6WJr-;2zW~LLCWT5`MTC0q+IOu-$f>JK#8Oz>VA64>NvX z*-x3_@s?%qhFSNlHWwkL80OA)_l947w)Md4+JjyHu30oqGdxwfvhYl+)pBifz&)4O z*tCoiX4G2a36c0rwF0SmJk*;iv@V2*JFiA<4h`b|r$(OonB7HA9v8E#8&-4hq`24b zl=`mIDe<1uZ1j6oX83^@Sh)7v;W}%W>l;*r6`lwyb{$GH@kWH1v;Mp z6%LO4KGQBQ1z4010oMm&u18he)^uE3=-`HbNGJxHFGFP|Oc<2PM44#~J@MFzn? z5OtpyIObtX_{~5gd})II{fIVpb`eVI4ry(IPD0;cN!G2m#k`hNo)mHsz{ zKK@mb`uJT_>30A}o27phI4b==3Vr| zT5&(}W$Jr5ko+X~Qk35j?T(lS!W)i|$f5oT*1=KDZ&jSXo*-hK_@APDdVjw|K}l^) O$%;aUV#P)E{Qm(1=Or2d 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 87edf70..dd61a58 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/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/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/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/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/.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.93o87lhgng2qq8bftj8op6z8i.rcgu.o b/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.93o87lhgng2qq8bftj8op6z8i.rcgu.o deleted file mode 100644 index 609c32961f873552f16fd7d81e8ec02ce1ba68e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2560 zcmbVNzi-n}5Poh7El{CQF(8$}6M`XDevvo~5D6$CPy_{}4oD_HoF?GJsqKc4rA0yr zG4Yq6E{KHz!NPw58)B*$kQk84o&7F;ai5chldO05`MdYsUEXuHuisgnQxry~u*)or z2xaWkkk$lfERM3ew6$EqIu@PV_Mzrbj&U|UE4FY8DPzr z?ro!J7>0k}G9~UARm-yL#?W+Zv%w~1=$O&4#uEbZscLyrb4*`vqELz1| zgNq}cXWHd82aDn*fR}X7h=(ygV`mxSGCVR9?+Cts3CWZ4#k+%i{HBYx4avA?Sp>n~ z<#nIoIObtX_{~5gT#eDc8_>q?E<#cLl^Ff65`F9?s{au%S^tj#N1N9F2jIy1|1QzT zf0d|y1u&U@4{)?;`nP~1)BhvU$FF}>e=lbKJHSQ5+C+=aAA3ShS|ox0g2$-{PW#b{ z`;jkF--&_XN4Zym{2p(2#N6fH&Ikz{>Yw0C;K=5e4n&4A|L+56jTuf6u#Yp?w{`<#30<@Hy@V=>QMG4En8j2YDPhUdj?BqgDg*Ab6-LH|?L z!C-x|EErsptO^F74u;Z?jiZDtj2?oEfOL_)nAc_L`r#jfYKG~+24lMzf{fm+b=BG$COiTp*`>TS@>15mP zTHu-#*&kk%Y>eSglgHG=?jTHE1irWJW7%4Agm9t0OUV~iOq=2%LxPgk)uSdU7FegN z$nt^JW>2O%0*&q+JV8HyZ%9r-L&t*64mLGsg-(o=(>JCg?yI(PVf0zv=tUQoGcZ}2(BdP}ksv&$jZAw-~( z_5CBau5QZSAfl2QSXa>hfTYKIp%%n9dIKAh`;iKPAI2?~jb~3|4pwU8CP^BoPj&|V zBeC?{%)LM}zft78jb@()m-XPm*hY2B=eQ#n+N6{S@drRh{(ZQoHl~xEsezrz-y$NC zJc&Rscp!+OVK*h~pg}4)#rx(BLdJNY8rA@p z?oY_0`Vo5q`x%?)5O|0>vT#xlNLGnv?s@_87VB1rNY}-p(X!0GyA8Y~6H{tgI8sQF zo3d4>kRmrF1)VVuDU>jiGab}dvGRXI6Jib!d+ArPG6C~LeGS6rf#lMBewh*wXYn7Y z2UvAj1l1F4o`6_r6SfTunbKk~{lOr%H|$(nSO7G?RSJfelXyBAkgtGn$EjfR31qHR z13O9uL-4=B^buwqmaLoPUcb4Ngt_L41{NAvXamgwe^`LqNUTrTL8aMM2d!q+bO)t% zAY7HXi7g6x(GDc=*oKHV>dKVqc$A28Aidqs2h)7+i$uv`P)TwK19M=++RrH;w z$Sz&hTG0lqM&cL+$x%Qxw&p4l?w6yTQsgelx+%$flp=)&Qe%kYXb43n3KXfwE*Qvm zv_;s~eIgPZij=c6sp6bpKturt7bJ6=x+F;f%d~(yw1B)v2`DrWu!;iW583CpG{$xg zJGDXV01b%h%BEzhx@;E&^3MHs?!cNpgEC`w2Ei++73VlcPLztN1r?{JDA_-F zm}iuYZ<*?KO%k!}AIKMQ>Vx^uh|;vE4u7h_d2URYvdBc1GS7|muFk&M%3OzpR2DLG zI)kt>isI6rNcPemHRP+(j@1EAAih)=cD7HdO|W3}o} zZ~rlpGW(d%j1L2dpi6AQ4bwi9U&}J3iq&EV!E+FS_L)9G8H~69JGVW}G^p~}x?T57 zLk7%zZ(@IhLbx~>+#B@oMYsWVO&K{c`W{nRbxl>}bZC+tL-6S^%F3?~o=jt!cc2?~q?6j{IO-~o<-7)9lC%1nuqV;PEIFfv)es3v!?KuH_a zbjlVc^+6G6NHy`MFiY9UT^tYlSP)VU<6c<{2Jqm2f46&NOFk0MOox@3xOam~Ak&F`3?AdmX(lhV+6Q=^S9px(+ zuE9z(0Ij&M1d%RQOy=;2tYw6Ab+^`>gE1}@Mf!kFALbo#c1Wfl(DAK0E=-ZWO~-{V z;@fpx_#(c8@y$DhZ@6mCT#Z2wb~kCwCu3iTdmoC|c;4RlBm@t|H^F*1exbG)mElXO zS$F9p)klydswfk4QR`vlUhLpd11qRiP8OCW-5z|bqd;v+;1>7Q$r|{$^7Fh?VE30! zI*+XF1^XB67ayXm7zv6|gGCO;7~LV8tUXXA&;T`&{Uz-oL2+udC^Yq2wJ7K40nbvq z`v7t%-j*L*kvj;K=Am$&K;A-$`{>;##Ew0!^g?e|`CYNtb=4Po?Wu4k00LaPKJ^?i zxV?bBP*3X+ABPzcNwBsb(08`_Mobk^=)i{L$B7PIfy%1(zOp|P<4(bL!N~{4+9wfh z0RE+-nWgjsFzKDzOmqRJ)1gm8gaB%3NJwR}Guh8ExY1bE97*g48F#MS)nrWWFt3S38~| zxVElZsT0+^izLU}OhafD-8Z3=Mmh&l&;bt1U3W}WN8%T%c()_Nt1#4?WHfw`IfzCL z3rN+ngu`H_*1rt#3MmlWBm^v#f-=KVO(JoW%6RwLMRhZD3v`o)_YRdbHCsvk!UQta zWzVz4X*IE@r08Y`q#zj|o(WBS=e`)WuiN4NmMu8 zxCo?RwU%AKW`4=By{FL}(&WtnRY|RAGdobD0_|N_<>(Bv9%C}B;@Ozr4F*Fpa)J}&OTNu5ZX|c)rU5j%rnp-DkIn5{oUXF9URr){hjh{fA@E? z?f?I=zmw-l99A&Yn(2hB7`Rh%ii}P&KxW(RM48>AA^U_Hxn#(MmrikFsAkt{K*bah z6L2gvxOAyU*i2;MfVtQ=6YF5GBuNqsgQ_4YRL#w^(~A|V=j}r>9mgXn83)K95C)jn zo)J5(qV#NUO8G;v*piuNdjqlZZ87i87~>y{UEoY|)p`LB_MTCX^6ay_&7k6Jrc`d2 zb61ZZ#4NHhJB1T)wV=p+kR)m+0$VMpY;E>24BwT>Smk}g>hV%~np8Z0)rR?G<(|R3M6PwvGL#YmS$9>J*LkO7-4#^Vi3ES`^3NSk z#$1g+9$3BD_c78UvT~3XQg%Oj5DjXcMx!2oA$^0i%h04E_S75a9_j*_YJ1s#R}Cce zmiUrUdF~10D@u$kFNc5sOS4z}?G~n!0lQ&F^a%{wPO#Kf?_VsR(y!q>IV=Rmt#`@F zq~rX?CQ18*ok(@X(Yue0jV~)b*{d8|7ptzd9p`_Gzjc?{ms^Lir+LSZT@X7Ztb^{# zraz$rL;cuqgkIpL7vrDIh~SB^2-X|T@U~@Y6GH12TB~u)??kKKYOqOUe-kQH`A(F^ z{fDa4$6x8qgzvj}?CWBSPq%tCGC{w8Yx1CfQ$Lre(dxh79H>8RYFz25-f?3uj7^Sg z4_#iU=g@l?|3P_*r#Fa{VIL}pt9G#j(*HM(Imhp=r0Xr{hXwgxiho`|{)_YH^QZFv z5`UJLKexY5(KS>iiRa+CKH;&P+dG#wNL1?F)LHH+Nd&3$)hwasgtqq3d_E2 z{}!~8%5ZvK2JE{QP$*>EOXdkFj8xjbqNLKvMyeezSwcu*N(f%O-ZoijNwo@;R@~lg zvZ7w9ct`aPm0YxU(8sTewT&<3n6t-^%^Vwe3ri7bEM4byGjM0D^xl~FnHa+dVx_-~ zc|Vcxv$4|m#Jzzy@z2LghvVM;5tv4^?6HcP zl$?T92jD8v1G&_(th%CQ^-juO#7rtnlIu9`(q_L%5bICOzL9m~aRW<|m3tpMrtfj= z@IN~C`zU$Qg2O#$;DDEUurFm*j)Sy|D3Rfl`4yOjI;1cGIR-_ANzXT>ZbWfh)&!}I zb5xmoA3rA5_uETSmG?b6_8Bad%Jx1sUdwT`r#gGAD@UdW54$p5_>)_71B-4bUUUsA zTPCJJnvKWxA0D&!@NrI+#~y(2i?Z8u3Mxk#$do`HKIF3V0oX6vn-9a-=={SI9G756 z_^34N;L-lWK0~$GH}}_Ivzbre)uE=KdHevL#*7{=zIv^JMn?~3ejFQ0L z{YZ$$v|!`0vY=dk_6erq>Awh%^C0;bo@0)?eEp*_Z*OcPZI##G_5HuB@7tc*PPGrt zMzh&%3^}X-o95w;>D*g^fx(TbhlYICpWw6pK4!!|M?iVI0J-Sl;(4}R34h;NybU>a}fD46Vpq2(77ydF{p=@5@2XQd|q8??6 zehB+^^ig>FgDtRR44rQZdgqi_?+J3y3V-IrxFePzgX~21A)b`iN3b#nh{6}6#gKwy z60-j%C989u)F~yLegMt`>c_y7O`wE1vC7*&g3OsA{IPC89g;aLrI0C$2Y1#d_OQbh zp0CJ*GLrQV!yPgpnJz?e>3H_b!cgVcC($l&wlh_!PeN7d(3l$IvOmr~6E2kNRPH~F z-aR#T%GI0?eJfV_p_uo4tQ_Hc;^qGkk9{eAqSJb!Z#YtHJyFS5K;zO>JbOcP8~m-X zM5$&CnHqQoOMJBOwPgbn(WqGMOLZ2Pscje4ezs%Stxwunb@2fR=isGT(#h)Z;0ATW ztxVKbjk9_d(qI>6mDiNq7vuU(I1)GbhRcze%y)dD$7ZG1YVk2mBxb z9Q%pPnq5&5W#qZ^Mw{V^pNv(c+V0M>>%#-ij=^wTn#VvVBiqmrQ^7+fIR>*#`N zD_56Xk`zr_{cFAB`R5^D&W(Q^H@G+T-ho`OFuk<eSAhBb^h9qduuaUPJ8#7L(ex2rQ9l{G4zblXBY;m73> z`5lb&WR^-N9aa%iqY;!7Y~Cqxsg1l@BE?kRem8{53{o=Vw|RsZ+=g+(5kvi80QD!E z-D6Rf(g;YlAd-duS80m%r;GA||JQHGm?sR-8TiSb8uAYq5ph?1MDhj=Gg-rq(wSOB z>FPi`#M=b%GX1EAJOU>xfK`Z7>C9YNwFxgI@qTBri}&ewCOxK4-;_K`WlqPD4=NFj z!F?PHq+S@s{Pv0ac%X$ooE zkWr;QF?#ovu}i(jK>S$jgrT_iiTFuBhHN*V^Xn{Nmx zZ3M(fFVW297-_8|2>_#%CAiVfHp$y?D4qaqx(n4s`dZwwyZD|Fc|)yU<%YZQmy+_0 zCrUaD8cKQqaZ0)aaa}F_QjvbwL%X3pXOqF^JB1LwXqpv@W)6m8%@Cc8R87jl$GDrM zGoW~(f}~DOGUv`ETNNH+_UXC z`A<8<#C^p$c&SEK*9;BSpa5Z#gdzn2)SUoZv`nQ>wCMzpP9VRMBvWudR3bPI3(`gf|Ep4I-QnU8V%&7+Bx{jE~0Hnw&#M&MrIYi*y_8xak zsoWro7Gx1pWH~G)sFzXHt85WgN5UMdI|2X|VeP?iU9n5r9oFj(jz$uyYhQ#`L*}6F^E7S^ogi{*kMD>a*vBj>j=l=nAS_ z3au#>-nMT98L^VEjvzpM_c5l4GPj7Na-Zeq-l22vvbhD}&`>-#&!uaN!w9W& zZ@0MxL2e>N%0A!Cy+!BVYI6&M+(e4xro*#k_vzdNHn$+iO{7Tf9IbWf+-q%aL6DnB zk=!|DH|pGNHn$+iO{7TfT37N0oqMUxEeLWGadKyF;aOS?8ni$6hm{8?9!_3j=xu1*~KRJL?E$@gg z)$uwVZ`AP=?_pmfxfD3CA$ct|BlCBQW&*M|PPz-kC}J&c*+V#>%M5bbJ&NNslC1SYRIU59j4mpG?1KTm? zwqUp|!uhb~Oe0X71p#LwhO=E5gEJqV2xkV!nU5Qaa6Z8Nkolm{Qxk%KGZDktm3hBV zLK6&-GY`ZU;k<+S!FiX^ld~Y;OvG?5Lzs2o%PU{TO=M<(oL^g<^HwC2^ERO;XF5lzpe+0f#R^{y<m0sC0% zxmGX6;cAz|SuTeZImiNA_S@HUtGyV9Yg`VexEyN9p-#zAOBw2lbGWYi@=y;oE{6m; z)F=)Ka=;d#BSWy+aY!MHoc5?9i)zK9iY%~H4_SQk&zVzYKKm@X!H;|@cGAI^wv% z`YHCUZUj*ZARo4Du!Ef%_+#H=F?@jL3~UCSiLIbB+!M(& z`N~%QZ7oO?WXD4eQBi@{NOizL21tP3%J}D{>k`Yw>g@?idpw5y0QLlFl zcKS!XET7wt^y8a5W3uDHVDurvvFsld99LxV>=P>PtGJ*2p^BHNcu97jikGT*X?CZI zAEV;OWbaY&F)BVLd#8$*sd!m-gNl#sEob>-Gq=E|6jZLD^30noG)_U|GFMq>yn@DO z>MS%tK@&1(Sg1ll6`A8KRH>lK%qRvcRbrKbsxnVm=vW0EoB64Qj#JQanTIWOyn>F; zeAPk|6*Muk%|a(A=!DE>3!SK-6EknM&?E&-%G_+BY6VqimRRT{1)Y>xXrYr8baG~< zg(fR#a%QrHPEpV)nKBEVs-ROd|ADEw(!^;BIxX|Kg{CNIO6Cy@B@~p%JYb=z3Ywbv zoP|zT(CL}GEHq6)(=z=Qs!>o)rprQSDCmsL8!ePnP%`ss3!SN;Gc$87be4k7$|Nl` zT|v_`#{#0Ijb_Fm6!s1{b2(rBbTG(Au=0Y&f#CZ`g55XhE0}sPn7%jIeZIc<@e@FM zGx1>R_vKYu97@XIP||_m{z`oXQxS>jE2#W3D30)X=EVN>$%(OEJpU`vPk8aZFNxL5 z4*EN6W-@RC#MHAR{Vb4jQ(pAx5;F@{=Ojb^o_un&X<7eBa?LLhDCr-Ww`PI_ zj?_|;?8z#*eD&`Xy0HK7ulk3}nZ^_xy_(p4V%`y?@kp%_M>LTkIHK`%M4%rJufvCM z)eouXe;fz+s1#&y>+*Uqw%Y0DF!IV-qp`st9uh*+ASvVP=4wAoyH>P>9` zDW3o`TR7%JRg_f@3D_KH=DFUp@@11~u6m%rbYcmn6ZYXDRfwmRo0rV7?<}A`!Ik_T z983gH9Mj2ajwT6?d^P7jswV{af(92oURuTrh>UXzItc=z6#aD(h^oIX0%hSH0~y~^ za89FH42G-<9GeQ?OT|A zeI0pY*~`M*S0MLz$$bTK*YGrmeto?W-x$GT?jtdijlEVskBVo%$_nAJ&2}*4#cZer zl-@s5h0mYiyb)%jkfIEc(o8wG!O$riV?A6eGZ$*yMZvtxG!7bgP$5z&ONOGCG72I1 z&M!YaATL9s`E~Z^9s`5fm0O0px?lv7w;D`lV~3#++|gy$%h=5Y6jwKu_o+J;B3lr} zJd!t=N9wf+mMVNK3H}4PamBKgckHF`PG-Gt&3p~FusoTistyGi*Xy#;EU#RdmA8Ka zMP^eFi_$@=dnkgsA+|xq`cxVfiycqV?*U&C;T7*2atz@*Y8K{%u8)mg()xD_S7?B1 zQ8eopabt~sqN><$dZ%3Tz+^&96m;(t0=dY}O9$0nQ8@*P?2oje;7ifz?qm z)jQ-Xc&K7{(I4q|PvBXH#bdz1EXLGUv=@S+w|F5Ypeh<0XZ`&#I5AV}dINSg*Pi#OE7 zG&xUJisD)c-`Lm`EB$!Pdw;BaPb~J)nDezQ$S&teIJ)3KC0Ye{`Em|x1XorQa@3X6 zs;K3+w${k10SP=f>C21#>ZpiV8P!r7_zOjcW%@IpT-89lYWWamz4-**ceEbi{+S9A z>T&Pms;6c@JLtJQ#l(*e@zK+{I+n3m#E@u#*vZKs*l7DQ%3fI^=MOca&Ji zh)}TE7!e9Y0xrR7K_+bLNZdu9DTdZE6p&$`5MbwH7v;$R(ixtzOQ!s9>QGZMW+d1k zU3}=YO&IZ`28e8zh;p5bv(xi&nh>ea`3Z@9oJ%M3bACKZ%1ZR{4(ZBJ4n+mi4QlTY zpDQKk^}d5Rir=Jg6f(k0XfN?JI(!s&UVH>4>7ZviHLj?~PTNWjNF${l6jNJ9g5`KrK>M-rfXZGw+~wRJNd+ z>Je4Q(p?R8{9-IIuk!YtELYJQJ<$MA^**HVq7imUfX>~;vyOeIMk?CzJU1kG( z-5Ql`WMaS&tX_ZSEyYj`F&M6V4NX4t#(5c-B$T3M`YHOV;bCP`*RsJFs6(JSGZucD z4Wg>$ifwuWMK;Z*q%DzimumytWY`!f+Eyf#SubYEnze2!7MIzJrh4%v>8;M5$?8-q zozPk8ud1nhdf=$e{#q{|j*zet1BaQv_tAL`(8X?{nzu3^LW`2y7{VWDe^K%P9pQnZ zeZF$T&%lzzeZl5>P(s)|*t^h?v<=63tA|9oPBf%h66re$@KDnJCK=sKT!4Pl+c9GK-;#*07nAj0Uw=*Pd zyMv)>V!IehFjS?#0a$G;Awf$}4y}($9;WX8qM9MocF9y!pioGBNsmUyt$U@)ft#gJHRD)BXRnnjwFl*ROkXYkrPo3&Sv9i9?QgQHvXy73WMNv6y ze)>od`y-EWgTF5R>nt9o0G6ma4xAeMOh|p@<%7h-sfDp;Q>j?*`P{mfg7p3_If(*l zGyS4MX;X~&XUi9>*397tk~p8R8pAtAC=pFeo59v)FoZS`>qsF0O=e57ck?sKlBVYa z1=WKwQCC#_^n-e?rR31+Ub@n}ov$L>t_5r)Scz?~NjbFBpIvkWs->2sCHq{bmL=!QT-Urd>7HCiWqtFW(mV6+I;VeC1*~B7`TU*>W}Qp zFkXnvXDj{hNO`?ys)=&{e)Vdl#Evi@+rs#?Y@n!y7N=A(yxSb)3U`%ovY`nR+8@`5 zegMGeI5M-DrAvjvA@opAjJ0{H211VKumN$GB&%;5?E_6WcgMzG~&k?Rn(8%#v) zaeE)RmyAD({i-Zi)Fbg;nI9g-D`H1B zE=gwkzB4>fg?GiW<&x~DOS02ATtHGo!VmcWF4gzF!>NJVfri*X5a)M43kWu@Pi9jC zb*X`_*g!*kAn^IA<&6^rvV)ozhh%G^eEx1P)R+6iWiYfdc^zkmr#SVGjP_2@ z!Md?f`V71lg5FiBxBYg16+Bc=g92oppe7M2kGwh5Z(obaDK8krs7>jyH~v4ocU}tR zW0M&s4c7nz_PjltVb*3RR=&@I_e17A*8^XTAz0JA-=V=I!nqoPLD28gNMA7hXfSX8 z>YU7D&^1f}xtWm*`Df#WP&{GF$@A%`0zp3_MdF-r>^YRx9mhjYN7P;?r{XX5c?mLG zBeZ{@U9s#y&~0GcuHmkAEx%G)cO`e8`8aTKj9;*%v2$Xf-!wx<Dk|I82@`N4NsvYIHrl#if|Zmzzqmzf%w0g`s^jX#$Ark#T}%G8WtQ z_;@;bJV}Az$HT8bX&$m5kQt?!a~ZM5)6H>NrrUb1(_AQIJPM)v=vQ0gqj!&umA>X9 zu{Y@>-O_$=3!plgvW7PE4W}W(W(WNrO-b)@C`?y*JLf-)Irm(^G>L|22v%J<{#!MriC zf#x?md4Y#fU zIP9Q??HgCKuPS2c$yr&!VRTe|dzRCd)Ke{{AETX=E-i(N7=DV>)%Ji-cP>VH+dcbfk;9;D7YA zJyS~@4_pq-sLvi}AMbdNpl_5bEk7xya#Pv^jN-KR{TlLqS+`$k!t90Rc z2iE*lMmHs>{)_TQ^qa=gukv31q=5# zxi3?o?+x;7lj`MmEp^?+h)^K1mXR7sF-^y1mTDKXo-@{!zc%k8J8)1-(};bf$s&tk zVOVZbXN)0UBh#_;ku7kjjRG-D(d%^Hr8=I{amj=9fN|ul#U1}kUWS=hOd7m|2jbgw zy6`|;j2G#`194;2CO;1~CiP-w$IwtLVk5c7eR$$wC@0_JX)y ze-JV|7xS7*K6SI)Ef!^=!m>A3 z9b9-tS(^f9?A;NG+fF)WbBpamfW5N6-315jttUj zCmmeSh9bDzi0Xq4GGElQKK;dpT5xlnpPO( z3-n|GeGHR?b2z1gAyQg2-NolVyPNv~(rcjBb2z46973GJ)&{pd&t0K4kRRhTmI{}l zNiOAU3b^q}_GNKs{Zd`A`xBtm2YGnbaisFTbn-GOfDd7n5}*xy{~i5v;6XA?EJFC= zMNrtH`ax_ouEWmQLK-P5x~_WgmgK;7HPFB!{8j+$-8lp!O^P3eG4+Q1MwD`u7!#-A|yrWz)49yFM@U4O&)GkmCLfgMQ z-)1?=29drPVPzcBzHo<7gF5x9XF+IasD$F7jV_=TM0;wjCAj%gyx_{GrChv3f&dg*WSahQ|;u@ zo<=6=sO&O-?e^SzI9^OXc6{m5e+~K239Dp-R(4n7`0y7rg|0!{#jilV)o9Wt}6F*jRF9JY>iQQv~H;mVI>*8J1&?$Q+*PnYV=IFmxp<$7i`nKBD*jkD9&z5$^pf z2@W|D95(JA-HzisgaAi8^R?x>?+&N2`C88bo`XJ$0(Xq#YFnSPV(9}AgfA83`}cE) zZBf#S1^VWf!rl3ec;s*um!DsaKipWM*+3X5%1JH5f28_p+de?HSfUc@w(S%-YE=CVY1@X8K9%JK@3^UPqN?x z0Ya?fcz7mpr0*aOiFQf4dDtkARn6}K$#IgsnD!;u7>$+prLh0cAHc!dSpB-rHpwh| z_QqG_7p)%PXMF5t(M7cF5yzTyJV#C$@*8I`!bX~7TjhNV^hyGHIw+FrM}#>0$v8)k zG&WuJqgp8N5UKdEz+VVW=JW(B_8Cu;e4a3_m-iqUF- z%ndccbU7+m%ef1hp-z6_4}p}Kk_2qP8Z^u(4^cw?fbt`m(^C9Cj%q#%vHBQn@iu3h z!Xo6vhn~LH=~g?;TA)?fQ^LogvdgJK=@#;HgVHM1(kf^X%>72Z?uiiPy^>cUB~tA1 zY5;*wIAzAHehR;I3Qxv$lgwwe`gfzlB3kP!ueh}UxDcQ{0(ffM%5Z()D$0l59~)k955~T9R&5!&Z$%sl8bMu z^q!l&Jc>Z=+)L4uQ1m1znj@1Gr@yhTlC!_U%5M^0Ygd|5e{;tttC36G7XvR~heOw+ zh|;U|>WkAJuD%j^t1nFBrtsvC%uQrDBr_yOOH&}~lyK4S2-F}GuC>rbmuj=Hs|l6u zh&qqbuWYW1ML(@-KKQ&8Z;A}(V5F}f#NH6hzKk!D4Ds2C$%W|)dXmb;`eLwmCvU7T zw|6l=;bf4rA+P};a}+uPIpcOMJh;3t31LxQz_y2DxBCsx9V5Lt!4iOTGy^x;R7l}i z)yQbNE3=hliL+zJ6B{Hsx+`1Z_TlvLbX+pMJQ7QviG7Kr`L?U{Fi3*lmFw^-viCAe zLXR(s0ftYk&sh{&m?DZ$Lk$6@3Jvu`sipNpLF4|CBt=$TA5YC*l=$3Xkh6Id`G*;_ z7yRQcM5vObR7oTMv;|rv7!qZGKVXj1H( z=g;cW2kz&GaJp*}vD7FhTIVP=l-O+4oMFA*^^BT-Sc&-*>jm&bS-{VsY zvZ(@H%Rr8!fcAx|?E>_A%!(@TTxgQ#N~1)XzIABg1u&N=Gj%BlF;Gnc z2%9teF)f(d<4LG#l$!lwjxbIdo6ls10+^DFohg`-EcEmo84-C#jtAQq;Pe=a2Tcch zUe6O%7`}rc^(GPQF(FpL76&TFryf=U40b2fGRhqM!}NT=C_M8ntv(nw-2~=$r)KBo zl%y8c>L)w1=Q9Zk#RwIyt#uj>|LERp_N0`d;WG*!M=AJj1YhgroM;ORm7^{yocRw- zmolr=xpbt-h4mL|cw>X{%Z2N)yx$w*^X@fhX#6&`b&`e|?)0zw#}H5);XmOgxSx+T z82Xf+fAvY!F#zkE-2D zHjw`PCOFg*R%T+}1~I^Na-y?AjLJa$4+|eXTQI`luJ{}7!oP-|RCXSe@}Zm(^@GGk zpWXZxwpRk(*6{bQ%Ep#Y^N!~SlJo_6*ODJhof9mXf#P{Ut$z8kj%0L-G7_07m>!U& z0qMgzBTms1^^VVCPP_>2t{*?yPRkCcgQZfr%x6KtGi`y_i?8LEk~LeljtdR0iV((_ zCkGbQCf1tMu^z&QkYy-lH^vDLUrsEWW6S*A4!yC2P3YXmr$5HW2QILpRo+gQoB5WO zAr-6Kz=2Nz#?aazLRa=zmE$V!qlZCEdM%W#q%|L;uN;SJyrIPGp52(elo?)!xx%y% z!*2@1t6}ZMvl|YM*-9}S`)N1EdgVjBqKdjq3>`mnA$vbNg?)&Zc|G560HjwgaO4=W zMa8ONP_3Mc-+w;YYx2CNx8EN3ei{1}n(CW=>G%BDgMR)`HozY0lzqV-s%Q^zOMlUP z9gRuzMBCS}ZS-6Ts!VV^)JbS(a2A=VWjJ|Ri<++#hvHL~gS5>hA4H zH_xhD-n9I@IV)zbm{ZqU+lo-_^5t{q&8c19dj9#dTP|3B!Fjct;1~cDK&z>299YlkT3is~y8W%#ddZGO4J+KRWQTf1j6RG?;e0oVxa;TSSxXov&C$ zDcS0#mF+D}(lD=(bgkXpozP!vSNF2z?dh(j=C`!IEF-dt?1H!*ogK?MdRMM|IkH-Y zJuBOrTW7JkUlDecueYQ9mR@ByZJn!HXRSiR&zjlX)O~X&RC04WRK89%V@FHt+L_JW z=m9-5+dF5RJ8$;9<`s3#bC#n|UNCpXte)2HmeysIb;h|fYiFK!-n?1Md)rsOa;j`b zUraB9(6314o7AD-7+R<*8bZhKiK6Sm5V745T{m!V6%9D^}_k<2=KUM@FVThwzFT;t6ht6DqK zFGoJhQPq28)4I;ySB4?oce=Z&J^iveT-Y4!AU$2J&C6DFcdlASx45jMwI{8-cQ}8! zN(cENW&F~Hy_V?^4j_4F;a_tTe1Cg-U7~Y!Yxjzkoog(m3e@ZPQ#fKF(_9_DM&Iwl zoQ^Yab74g!*OQpIPBb9yEr;x2ua&Rgd2C>Cr=tmy2fUvKZYIU)K_w6E$~ z*-Ar^fi%(Gn(poHXl+R}btGgoOsr{Z?MS5CS`)o+``s(oF)Ny{wP{tNrM)H5(U}fQ zx>5(%K)=U^uxI2lcPFW6a8-L(&z#wPhr6 zW(wAKV_%yPhY0pE_REhdQ%B}9^h*IyZ`a@OnkARbm;s~ifp9X*PT&I;_L8jy#AMK) z-`(2X+cBek#SCZyGs>2W%phK_(>Y6@(Y)g3i#j^tyjqqur8`%(H_w?}G+pKdu%PzU zt>qkX6Dwg?J#C%6D_auFTM_H&ftkVV&b#1(Yj0SxY|)k1Te)7ZLws(@Met<{uU~xa zvWBY`FMI9f*T3euyDy`K>G{INEZFJoH5;`AvbFY9Pp)!GB++WHb%loph^ycDUmyg!?8&g_>U zO=H&EnqJn{)T51hdifIA+~rHcHfG4QGkwe%_}A34>diefdOP0I(YdB$M(e7c=I-{c zbZZA`dC?wttpVZr=e-D_BJ)nt_Z<6C{jEU4^0jqymd%;nKBsO@Q&V?y+nm{5ooiaV zyU-t@@NV3?+k2W<&zW6>S=f1VO->bM4J|Seb4te=qRCp*#nOtIM=?5)LkyYE*4)eJ zHGh0*#tZ@Oyz%dN$s`|+G(s>9FV+;DYq!{>O6Ur>R) z4E^gt?~YSuyn7Oq9Q(~>)ThWxpWB{XHx_wovnG{@k zZQ?aeD_SudT;1NeQl?4s6X@SCtQ8pC6D_T(n|F4j!7*=WS(j*D+1bu@A5@4eaBLsxg_&D~9_W+sH_229*mv{T+oTidX81wn;7 zEwi(WvNWxPtQ{?h=B5ssDh#W;b!9s;a0UZ%3ZGs#^CfG6HQW68ohw%Kz<0__966*r zgw<&5X-uQ^XH);5^8q-BoW-N{*U%Z%PG z*a9{X)udV`d@Y@5C2F^IZF4JB8_xFrHdSyBeqONS}>f#d8`Zckm<(=s^ zy&i%W?Y_B}Jq=E+yHyRM=r8n7;34)U5w1zO=5Jp?PlZj7&hB*>9i(_ z=4-iTIX}@T-oA6i{P_!(TwF6f@hWTpU_O8uquvVQP%EC~B}YRuca!<0jH8WQ`2LL! zdGbU-k9gd3#+m^=(CMa_zQ_hfH>a5}O+C}i>6mI7!Je7!v<-Y(Ys)+;H|o32da5 zcqd>2im7AoiWTr&SU4x%%r(le_mbU28y?r&c4&5$tEX0&>b9<>*IUz`hU*MX>aXHC z{#&)mH=F;XHRB}zrCLj^Nb_1N-7TBP^aFpDM#BnEU1neOL992;9t%^h*6E)E?yeur zKK+6`+^ql2U1#@}bg#Pt4(Fx?2jy}NVHaz2|im(aXRn7-w2%hQ1u$$!29BgZL#=zq$oaZI*mV-jkGQC_%AEU z7UHhc2VNwdvTWDs(|BK4QPl*_RR|6zO+u8t<$=-{x(JMW1j6gO`hGMPS@!= z4M*UIG*10f?khC?(FmNsDauRsh(Nzs)AOsvyu=n%{ly0AG@j4DAp&oU!22}LJt)fg zYF+Nu2zoxho5ci5Oa6`JSyhP5j)V zirV<1iZZs%4vo7$iGJXK#wR1pi~N7CG1)U+RM8N>nw_vg>{mXX2%6(b47)S^sY*K% z83TdWLn898(YR(QJQir|ZRD}2A{Ad==Tcw~c?8 zcDK@ZLgTv>o{E1}$x)|q@fS#A9qTpr4~n)Ses4t?ZKOrx57GV>R}9DED_F3&F3Pn* zr+-G}OU2O`3oBCMbGGTUZ?Yd_Oo{uif{`plK>4I$o&G1<5XT()@#B zp4MqKIv=b-Y<82znV)j8e%my57csWQdsY2*Y5Y9mpk3pQOeUOay=9 zyB26X-&ZctxHdD9rA=d%Y=jon6o^~dy*wEHy9 zcIHLeLmK-D0s8(&741=tYZVA>i6~#4i_x|u_$eB{Q1M8>y^EdB*0{?f&=`Nul0482 zr5`kE{QZhYDvnM?-$uJw{~~Eyb=rGOzPE(=cD_iygD;Zylumob@Psd?EYxAyc!F97 zxKoFxXzVN|pxsS>nXU2swyf9qn-zUy918~WZqfK!;<$*d)mVcd3frVHqgT>x)0m#R zh+I20=9W*|?`qt&yTclju8(%%Jeag2d3@O(@hJ(ViyECK>sb10ObnPO zrSaFOv8^xeU*)3+DdP<~O}ibDz01WMc?LADn@DIsqOna%o>cq}Rpw5O->L9K939^H z_Jca@ohq$1{&wXfj%eKV1=NFC?7MwuqI77_J6rRof2KX0qw(1q=lDP#i!?ryIIaZ# zE!CJ$#_WGLSCm~^(Xft^HUQ$hV7;dKM>8+*=fW?li2?b3M5nvJZ*8cT)%}>eNj}-E z@oB#@=HcFle}a1(=j)cjU-lKAA%kr+E!N?PreW=b zhW2)ss)JeEw_qUE`47hZ`()mB5E%27yJ*ui zKQJ`Z;hssxbUN+S@E6lasQlY>dV`Y=)3fpq>+~r3 z59{>rK@Wz%f4?+sLTq0T8d3F2==2YT<;yyAVMVRjZc3+L74mQN&lLWR8PUD zr_=vn_%E*55sUwnHQd3b6Z_XRv@>0Y27ii_(ZKY5I-P4+on9NihAe8m1h6M`I@g7g zzPRG>82>*@NO2f_lr&|M;CZLR|7n`OGXEZ-=_^xmoTF(5Krcbgui&%II?Sx?zZa%A z#P4T%gVbZC&VQ;~k80Ed^t9tmI{hnQ{#5*nYCPK>ktX%mX+vSY+W1vY{dM|vrd(MA zo+k>VgCLJGJutjGZ zkN<9lJ(_(kM2lYUeT4dbdbC)g@9D;L5*MuYytsqEN~cXhr;2;Gvsk&#HUZpyPYdl} z=PW0Eh@Y<0elVH%GRx;ICy(#Jfby;v-b(rq-=Nb!pCBGsJ~HptX@1VhGX?&%m-J?2 z_q;w$``c@XpJDmP8q4w-r$gcGrUw}=#EJTHvW=nedUOr>kGBBFHeSb`PkhjpJ5#~l zj48y|S)6vpYopLVt|IvR4?1*yg7kM-dYLOB?StLKZ`D+KZxONCR}kk@4t2?V3BeaW zL;PU_dvDXBOD`op&C*|{@z^EA;}%b9`~x@y9{2bqA$76;@p=zl0*HGV6~SK=XD{DO z3N7M!XMT&33I_>;zL`XYke9_GAnxFI=(N_tjOi(JV{an=dU9F(3-xW^DV@LFYf)Tkw2H_b#yI9LY%1Y$*P<|d+lqc{2|WyA+Kpmh=;8$ zdwnVid=feo-oEiYh7%#+g#4V7zAB+i{LfTN)uKZ)u4cJs*=mNCO8KW*dJ}lpKQUB@ z6ZP$^o-uX(zLWfoSMWT}XL&s!<+j^$D;4bhx`Xt$>saWo2>9Na7B_+Z0}K`7M1A#? zg+Y70PWYc?WLshgM!oc~X{-X~#ZHuH)X#79XqWkMAMA*7D&z&0IcyaU}n` zdJmC8oT%^3D%;{hX5xL_F^YZzKPCDuTbpt_b+ve2bgFMe9i);x#5g=r43)DmY2nE5vi@Ib+Vf zZhtrVhq!6SJ8mQX21|UR4N=aEotO%K2CgUW;Gw4=eX4*y^)AwfxGDGfF5+hPq1M7C zv+rRtm3Q5VDO%jYjYm7Bi8uyHeVt+gxceTQO5Sxu>^a0|>-5{+NIq}1)aagajcX(h zCFv`-KZgCnr1S2}%HBmV4&O;Wfdx(I7Ic-k_fG2vjGuY-y~NR3^_5#LoKxO)NTx)E zILUm^c;7J7&!GCagBy9qz;(vG4OSi#_^GsGqh*jQ4+g8eYsU8o6yijE?;756uCHuh zxglP!Q@_$re7U7Hev*K1-=eX}-oEcJY+5hG8S?FErw~85iTp9C(O0{^UpkZc^``pX z`wao^zI~fF1n=6<@M}WA3Hja?6`_q7FluG&Ya5GU$;n6&0y zzv(0Y5I2@_ZxeASTVJMq??*?Ad#|E~Jc zV;u_Lu}=pEpB6VN;YT-3yk|P`vn`)-PEOyMS2!^Eceqgr`|l*5 z5V!o#C4R2uA8L?tb`|j1`*G5TxZyL{NPMHEH#h;`!z3>6I{s6n4{<~P#3zWSEw#Z3 z_zrFf1Yhk&CH&rHueJPh?LVxqqV~M|r%|2@Eq%fy z;O={FFH{L@Fg1xgxT)`%;-9$_uC9|!0C(R*r_Z~tmw9Q38$KlS9eujld=HbCyz67H zr#vB^%jd2U$sKE$5mk{UXFoeb=8s`D?BI9XAX<+j4j?vJb8&?%*GCD17gePD}-BcJf|` z6ZJhfr@ZUzH;{jb8#x!>K>R~i&Qd3z?|s^dsbEb%=|f!30wQVO1mcSXFhWpNX@NUBtbGu?OgISswSzu`tD-2Zu&8{(543g6M2 z1B3q_H!9)ZNc)DknK%9j8#|b{SUdNfoW4_E15Xq_2Tat%bWW~Dm|)!bO@j(c!TL(0 zISA&(-(|EAC+a(V7&n*i*r&mG_nqe&2LHt(%JXhpudyaU=tKD(7<{BNxsZ2!ihSfl z>Vlu)r25`JIWZM{=W9qG;^Q>MYhO!zo0VsQlhgP9*@>y(m{QV*xH+eLcro!-%V(|5 zSv!w-o5f8#{%St)A6Wio1ie`JG+X*NYl=_Rlm1$Z8@)9OpC4I1SLx7TK>8JyzCq(W zuY-L#iAm#eD4V-rh+rzaN-UwXXG`N@BOxb{-iq6hd8>4TyKYiihEOSxmYIH%eN<_ zEaY7xUGYc=I3eG8E^OeTi^+ey<)aOa*@;@`tz;7|Fi(#{&96;UD6AHN`crW4Zrn>CO8D$H;gW zwqCU`%RzB8||AOgPg+%OjppEKs- zVLvkX`>toX$6I;sG6}fjJch*$+#?;o5NA5hV-(OUJX{|cJ_--Z&BaeB;IsBJ${FHt z*Y?6WgaUdd`X0X;nR^}hCgX)TQJfzrpjY@Q1^7L?nNo-=`q0i9H<$1DC4=#f=k*2r z6&{|q$)yj^DH;5QYpI`0ET1tZ0e3tHx440CxQ6s^v3M?hxbJG{dB<~MOP`C+D8R=` ze+=;>3H9ZB?Q>^kS@e` z@Z564eWYCcJ(f={z9j;GZv-Bm@5<%#{s{UH6yOJUF=ru8)OXIgF>Ws3InQP=-toMn zfWN{&6oH5PIk|j3Qb3=WO8tj8mM8YY^NRxdtrJNf;^mf_a&9Z2XEL5g6yQJJ%alS~ z(TDRE#?6K23k7_bhW-BneE%M%6yl0LTz4>TF5fw4XfWQf&tJe_;rB-1Uy8tYMBx7r zfqyju|5^n8^$7g_2>crn_%|c)Z$;pHBk%_!@O=?@_}nK~|NA58za4@9Qw09c5%?n! z_`wML(FpuU5%^Cc@SjHDKa0SB9)bTN0{>+M{#XS5cm)1L1pb={{K*LXNCcjVz<(Ek zKNW%hYXtsp5%}*T@S_p<(-HVH5%|AH;D3m~|04o_HUfVx0{>$K{(J=fLInO_5%_2X zj-jE*h5eZb+>gLZBJk1({Fn%QOaxvQfsc*A%Omh{5%~BBydnaxjKGhL!1*=t!VB-M zMBpbz;FBWolOpg_BJk59@I(ZDdIX+~z|V}pYa;MhMd0T|;4>re+6esI2>iSV{QL-f zb_9Mw1U@GMpBsUjuXyF!3ieMT=;ue^7e?UxYGC2zdlyCE7f0ZiMBu3id|?EBX#~C~ z0>2^x4PT1yemTf zj~-K;{9 zpXVd=z@5xuddNiB|JNe$2O{`z23U;$84>opDZ)JjOGLRdBlN!@f`45EpXX?w&b|S*i{;`-T};oHMYIfH-saGAm1@fUKEk{xKc8h8{0yxb;x7GH9Gp)F4E-?dffwlyJNP3G{yn4_dh_i} z(z|*%jR3BEye)i+o%;kJUKKb>(b!=v_J69C}yIO%A;) z=Z746SI%!bxGQHV{5&tp>6Tlr@qGQvaOmA~`Fl{jNbi>WCI@%R?bA5z{OyjOH#_uh zzrf>AxyYYSi;VtPIym1sF!*f_?(&)H;7tynw>fy3gKy9{^~2S=;cuQNQ~q~2_@^B{ zZn=0IEf?iq?(m=P;4Xc}!MQtU%60wNCmsB)nx699>d14u#wkB{6$~HqT!lDy6%77< zO;0|34*rzJDgW_Kxupp6BJRqc6jb3~cIayz+@)XQ;AbOjlzLw}Zo z=kRxMm;dXj09=&c^<&k7D*P^oKgU5{`SpE`g5}FDw>f%q+l#xhM$QW~J^4I}e+GY( z!{?n2AJ;xzIZgZK^V#a~ar;-4eST8YlMi?Qj6C0UaPA@++&rnzr#DYJi2uo<|F)xN z?m8MiztT8w+{H5Z6AnF}))~BAk4H@A?v}wjHBP(jckmB5^xX9_^mj+ln{P#vzbntv znx6H#)sg2G0=P)e-7~{~jmGokG2VlSk4F{f8X9-@!kxaq{Ob zv7!HpgL9YI;PSW_X)M{$^YzpF14foVy_(*Z6Z;;)7rd%*EP%je?=8G4sLPqP%me;0xCl#8L)%R8|v*WbGQIR`NG zE`BINo_!Jc9tWR_FfZ!I^*=6u7iT;1BK;QpGxE6o#l^X%}F$mW3_Sxq&UZ(E`@6d5Gk21KakE=KC(HS}aH}#fq z%Ke|v+y86tTj1lWs`Yb4pg@uElFBnc!SbqeCYfY{h-uoiDQ()&76>R#CX-1sZIVf6 zCT)W#L8=r*41!qRQ9+9WhKEu`F(^n;f%>{uMeSAeR>f;jE*4Smf33aOo~+qtot`r@ zNm2jo{+c=S&0gzUd+oI!XP&LqSO?mme=&g{);PY61zW#Y0;aD!*wPo`2 z^AWy2zcxjeHB8UPSbkh1HG1x5_+t!zhT+`)G#UsA z?cux_W0(-%3jdA%Pc!_BOutzVM>{{l?EEFmk7I|?^LK`Sli>^Kgj4Y{d@aK<_KcqQ zGaPq=8~lF4kp_1&8vJW4Kko81__GY>diF&_AmInM^IV2=e`XnuF=_Na$nbL*?=Kk6 z{d@uiH2%!-w|d}7!tMU*8kV2e@2w2y?fSC}{~ojd5IDw!{){sFk5;Jm&vJ&(A{_0x zmigbD|Dv8_7(R#T;q9@LaLj)!%WuXD)X)1vT#LYjetVSJ^J#@@`;TLKwh)f_yWqd6 zFIX!|;U+uVQ#B;b{Ly8U8-Pk@s=%)k&$Il$ zfn$^3>{v!UPcuDpfeRCU9M5>ojz`2#V0Z@tkWf!0{5Sds8U78<%i7(EOwX~bec`#1 z(Q`4w+ZcWy)5HC|h2isQZ!+2kZ1%-t9xu4B@Rq*FiSb49d z{P@BBJi>4uKc6QY?R*Qf^HG+c$Kg{fKkk7se)|#2&)c1Oa5UP_+uZ@wu8|jaSsOiv zv-ZyQoK3i`XCc$`W~OJkhn`D4^jyjG;O=f?&$S+UzU-mrai-@Krso+CJ-a;g>_rQX zprYbbrsqJy(cRoXwS?RLnZxv)#`G-m(3A7fa~acvyV;F@uJX`xzlR>PPX^0%2GjF| zhn{f{J+BasdTq~?Z}QI~9QELw%HU@+oVWL0rU!TXoBWrs{9MoDEPsUM ze}?7f@wSU_+n@U(0155n{ePHnELSble-_J+zZpAY4Ci`2#`NI1kI8>K%g^z|VGMw9UJJW-ANf2RTS+Ki6|A!?~X2gk#<_nLQV?{9MmNEPn&zeU#-dh#$hy-F#d)loni2 z_gfk7F@&T2T+f+=+j`oV9=uz`__M=9&t)EZZen`ouyWnuq32N#JdZ)5iS#zW6u zv~X{i>sZ3kKW8&N<~^def0{h>v@<<#XL?o8Y{r9l^ydJ;h zq5sz`KiB^v!=C_OnEL%crib^d)87D@AYr+mygJWY4 zUoXYEuEF_wDIYH~Ob@rmtb2osiYBJ#6Opz%(0mG{(KjL^7 zlF@TA;kKSM%g@)(2N=%R&(CD|GS-gfSUA=XrVjm~`l^M@o>hcnz7H}zc#RAb+RycW zT%i-V(O*S4mg_pEAJ@b%A$~o>Z&Ik%gXbp3Km7de{mh=(EdL^wzlLzs|1pN++65-G zZwb?5?#zX(1=lHFq5Oz*KjT~xldWeBJHO!iPt)0z-xf1_LWHB9k1~5!vix|Lneju9 zNB$2poS(nn$nb~Y*y#C!2R=qP`i-~iZ?XKmy_~@EU&iblA{?NKB@Ew4IF|QvR^AbY z^M2qOQ~(J-mNGpUOPEj(KIOxd>k?L9JU^}pW3u!AndNU~`ggJXAAn<{|4mwr(zA`> zwG78KH!@kEdO;Z|A{O==RMWJ4X;^e z#_zmbCVp(*+dOzjzy?g1pZot#!fpTKUO`NDe%v#L$<9Ah!wUaAE3fGXF#mDz-}ue+ z|A_PaNv7u{mLIQKW5WDdrvF0<)pjle+l+qGA7cJtmjBZ%|G6yxy@X@_53~Hner$I) zu>8Cq;Pu$Y^dpY?Fqw9Z?!&uEjhzpxUv+3I*~sQ)~Me~{(BiQxyc{O2?LSdaV$NBtMD{HA|Kd$@kS z{@KRzV~k<4^P6>1)PEt%e~QZO&Tp=3VE&6({wk0B#&787#mvv`EdR|+&#i>p`G3!F z+&f|F@*Hinyi z8|~!z&AuJTT7hf7rXKMcA12H{#Bkp35)3!(%k~4$-x(mkXbT;_0#E{ z>Id&)@yzjWG5jN1jT(1&zj}j(V_ab3*U>Iv_FRqzLc)9;$2lz~#2f!qn%y|m(@cjv=kRHT8$@Ccgi1#sGejaxp!{1GMkhh!ZG29?Wqy9@-etur_6vJOFUNg_J%R7(R&&zuO;h3M7m#=T|@?ORC{4AlMfJ%?VHbIuV?vre}1!tV|-!4YXq3EUiz5+rxdFDH-0{~Kf}*wacK0Ro*?}X z@6Y*oVdgogkK@JaM>{Z~T!PtohC+3}cPA?^Z|}T6d=toGLVetyxEBtS!Hs=p9}D7X zrXOW7VLpDIo@O}ibusU!}9kMj`=^s@B<0Qa-k26JynEb{>?1Ena?187sD4&e$?|E zE7#KuM;{vf=KKirbNqcQ|JRtF|6%w`Ob_nC!vv;O3^036CmikkEW@v0`PVXhBjIQd z9veGRCnm%f!GD9lelJ8d{tuS_BZQ+E?{Ce0$7m1lZ_Rlz>gV&!^WZxs#Q8k4OQ9Nn z7>YV?@(9L$gVbq&tvf$Ld*6NhLQ?!7Vje+b_(p?=;U;vN-DnE$U# zKdyaYLf&WKztQuE2ae}TnCx=l+8`#ozBH{OI@!C00CjT!zaIbi9sGl{uP|s184*bpN9}@CoW10-y z@!+lYz|EW)?c??PQ)Mioh4_OE*T3p=VgF+i*K>~tFP7Kr(=~F&KeJ5-Lq8b&G7tRQ z9{AB_VaC$aT_pZkk@>yi#H(Chc;!CEGy2o`A?|^Xdf=v?;q`LwZtCS99(r2MLa*h| zLMtcY{1YDejXg%rl*J4_1H$Kb82%{3&G|L{Y+?g{{u|5xILkkSoi9AW z@DRhl&G2T1n>j81+|F=5F05zxcbT4HhJTOYn;C9m7=IpP_)l2=?F@gG;o}Vd1;eM& zxPk8YFNRk!{Fe+j&k4l*&oR7}<$s>x-3`L=VOOaCx!a)ele3yXLuFE8yJ2(!`m2c&V^A|55r9#g7^l8^ZDd0 z44=pJKge*r|H!1R3_pkA&obPsv7pR2!_B!1;{3UYOPHRSG#;XVa2>`Ze7-0q#FsO? zmEkKGZl0Hj`Ek9`q>U^;K8MhxI~X2g_+t!@GyFM*cQCwy#!uwM^+S_pFua@LA%>?I zZpMGqgX;q(b+i0E48N4&c%E+3^$f@T7$$9Ic#h$ZF&wXtnDi{e2N}ML;dp(=q)Hle z(SBT?H_7Y|M;z}%G^v5*H+zlnw|6pph~;0;aNN&k(lEnu-;qgoFx))r0Dt=m!#A@0 z+Zk@gO3Xjba9ocyX&Ro;L&9=>l;Kqj$MtZN@VU5{F#q)oZ)Nxm4DV(*pDS%(_>C<8 zEeyY%;iC*6Vfa>te}dsV7>?_`ChcPQ9SpCe1p+LW*|UT)vl#wqmcN1Fe4g3HaJ(LE zQV+vF%kT{hH**-2xrO2Pu>7M8zn9@#8E)2mP|vdrA7%MpWcd9IpGFsM(GL$Wyo%uu zGCa!gFEV^F!yjUJC&RzY@F9jj%UR>1BUbUisu>rGnW7LZ_pNCd4JCE84Ujg!$S<;!SH5={};pG$#A}= zHN^1eSpE@)|C-?sGW<6T-^%d+X85xV-^uWChX0n~uRmzQ4}W0z42J&?!%t`U9~s`j z@NtH>G5iIF_cNTYLvCdFi!A>L!*TziNn05H4~Bn-;s3|*KQjFDSoxG*U#SleqAo-M z2Qr*osPHO=)1ksoVfZl$(SPPK++0(@Us@P`BFo>&@RJzc%kWbezJcMVG5o^}e+$F! zV7SBZdl(*J_*RCSYajUYcNt#K@;}G$w=(<>3_qLU(`bPn{olawgBd=T;WHWD#PBm1 zzL4R~3~yujxeULU;oS_!>kB5WVR*uT@VSxUT@1gL;oS_sgW;?m_6~jNq@KYGRiQx?lzlq@s8Gb9n+Zld4 z!&40Z6vKxYzLnuuG5kq}k1+fP48M!v|H<$%hX0!3Pcri5q{tClSVR!}W_Zk@fI)*P~_&yA8XZSRRrx?CJ!#6PeAclXK;RiGP z4u&7e@Ov1348tE|_$ds3is2E4?__u#!(U){Bg3a3rk#lXpU?2a8Q#qB(;42z@Cd`- z!SKZlzntOcG2H9{KudZU{y~<1fZ-ov_(q0*nBmtl{2GSe!Ekr|>{=aTDlq;NH8 zoSU_E89mAtBU_su$n_6s!QBZ(;vMOX$tGnCr_vEHIF+uA#riX;zFfs@G&Kna=qJ%b z_5Ja_RAP0iZ>1Aehq1o&K;ODlUuUeVH;|mxkk8$gO_4LD`T(8PeFMGKsowro;Dq{E z-Mmz`zb8IeZP?nf>pW|dqlKW zFWE7$azbggp6szJv%0G*Rh!PnAjVXEMwezGxWJQM!C0sqA~>6z@UTmJvTto&EVeeD ziKVj?RB;tl?kESv%7Xg&=}fPR_WE_1R4$38<*Po?neLpkw0{1gNGuZX=}9N*GVyh> zwaJ7N?uH2NNye0ubbb{qwn^cxY((jer26`j6PoRO)nIy!GQDE<%1nBoKW~G}bQXTK zI{Z?7u|!WY-bYex*=hgbY{~1 zHS_Qf9;xo87s`P?>PyGKd+}T<-AA1jw)@%G#O7v`7u5G9>elvlF079^(T;&sPiHKf z$fWvnG4;i%$KEUv>rM5gGvNJ9R;gK;NW_xs6G_OHi+A)S%hIMhMWePz zJ8EM!t#A~H#a5+LB^0ElH;!T@8cx!xt5@Dr+PV^bxgH7@v3*9eJ*h+z!ml&A-lt(D4s-Uf17GHOMGSS_H(JDHn+8wWRv#UBo z7d3}!mW3~>jzw0cdV5ktnwCkcp(D_Q)sx58lC6>1OxL5ErK5|u5IYqolFJOnRR0u* z4lLE@S(KJ+xM<~}&ZJSj8@<81*>I#22a8y4pdb3ru2iz8(}9n^RJL2WyCn-Inu@cd z%&Y4Hr?81dJhPHogcR2}q^K^LYS(m@?x_c|>Dhsr#OT;sp6i6>fCe^p#BiLv0EZ3I znCMAole!pU-08-+yxVb9E6K*zrE=YKvz?8#Yck!d>bg@)V)gOP&P=!`)kk{J9dom; zh&RDk6Vt(0mq})mnYBr8W#{QB%Ccm3pf^dqFw79UGI97yt!t$sa}7B-vMGm1W9 z{Q`8nFw@FUue>Xj$>_saBHjlbNKDVm>@lY$+vrhHC|a*m+m%W8#^TvTDizDbJ5%fZ z$EXT5kyS&G8GeO$@|yiXFM|K1XoZeS_uY|&*n_LMJ3ki5fD@DT%kb~~fu5cwou?=} zFNE2o%$exzuU?wztnQ5mD*42Sgi++7v?kFV&(!rMbKU9AtP@H0CDNVA*g&o;3NBt3 z#PC9zmh2_ePY8{}aT4jCo@4^&8`(%lE|W|`VfuAv9fg=pFNDzvEky^oI+=-T%Fj<# z4GHyAo9;)F+NJ8F5hf5#O-`tD5NA<+FvST{u>4f0EgL>Z6-pq|Jx2|6H5n`hOhvE= zW{3kM9#cQo<}&eAE*r&+@mx9+#cX&`6V#kSKeuJuyiCF~#k@f*{4o_HduX8>|4t#V9aySRBJ10eo2cHFb_Kyha!2cA(eLOI+I=T zfu3AU&2Vvrscw#Tk8XD0kn>@Qu3%nUtEaS3PMAbH;pP3wgyNjvn{#Ti@NGg_@%49M zx#re;n~PnQD<&kLm0-4Um%H8%gVY+Ni1e713&LHymses=ak3B<&GkyPa5ut8`sTbf7HoCvZ;DyvIP6U0YZ?$Od|_D4bZ|8aV?%!_TVa+vJuWo^Ks$|k*3<@dgxUG z86vKgy^8j*so9`s6M;G_n1$B2#B+)6rF}iLrdqRNUHXFf;BuI%=aMT2o!WsctR%-X zJ%h1Ka#cXjFiZ5AkWfYLYdJN`6v3RnPGysB!;@lZ>SVa)@yhpXdw@d0Ma30Tw`jIHiVuj|7l{~)w^j~Bxj zRci?W847*amYt)V=q?zpQcz2)!wYVvi>Sk%7{aO?(qiToy6wAW7`5dN*#X@pb`EX- zLIo(SZ>&+F5$oQ(7O&oRXXqnHxE3mJ)61$JO)~7Ld7g?@TseTXi)=30iS-MWsaCh~ z7^a6&oF1wHAF?e`xw_#e{ZFvoJw@k}bEnC3c8&{;5C^W=cvF4e${=2lW90u+XV@W; z3o?W)OR7$^4laE`mG?Q-a2}VAWobh|kUFqw@gjs6>U_LPOfKuIcpl<}^vM{mfR^H} z-Fb`VhEPS|E@&~ZbX(sF*WXg{o~AY7g*WSc_xLq z&aNHaiA<~`mFtIVp-H^d5{bcFu0Ows?Vcn^mpJ`Tee#o>-47T}1U6#CdXjyy zbYBcF_k%@h@gSL7oa|fLhuZ*5sV2)byBpC9LqZ+mE|W+Cr*+Ctb+ml;9c<}rxT`Y)6Jm<5x}|tH zf67gY$}KU{kzTK-Ady&gV_IGM!Y1q4NNVT8Hj#9%>XF1AOuXvv-z4m~3uy21e;!NB zWg@8)E3KQbeNxQ6jMn(1SJ6aM%m&)5F$E@A9>G19EAnkENVlM_l;G$Zjny`*-DUbq z-4eKhvMkw!{Q~~Ruch?&_k%ARopda`==>%pj6HE|b#f5)!1bVX7&Z$61e^Z62C>2m zn5Or!+Zfxom%k+1M77u$&ZreBYyVd%GY`F_B96;qKf1>*>1w|;Ix9*osmE!^zi)x< z=1|o5sOvQxi#D!+OZRXwrA?LrD+s9({!{n+0dXkYA5Oe!HcD$~rRrlvI^$FOsm5aeq>j11ZWnp7$Y9*Y9#B%8%L~JHl(&|3Ab4R;}^9jxJM zz4tbrg}tJ%Ri-lr{!PR6mmsBNp4HbS?5Hx^NqRj((*%|C%?3hVkpZ9I;Jwl|8}CIAG@qc{VH|T)hI`>QkM0*rzr+ zJ+Lhrx6Ccg!1f8XOORmq*<8ut@tTV!?j$PMrM*ZkJGs``q-E~j{N@VirC^F%k2hSX zjkZo0yD-@Nkm?+OQ|?ARx;Q7au&)c6cM9%Mf~~>T>f#mboKX3LRO&>mi!*L% z7l!Fl3wB{t!)1Xi>;uvJ^_#-!)lE&O+K7Hx78aB8)`jR{x#rom$Og7d# zkb}ENGf5Z;J5#uYT;KK-q)x<9U=Q11JHTwy^mKy0L17inQ0rj9dty`4^8O~L4z?x1 zts3gaFPQ0-;Tzj79SyEV&MHPtFz_A(T`_ySQYMMtU2vf)aDMH;1iOC_?y2cZ!xXFs zw%@=du1PQTo*=i2>^1ST&MNxDp>$UlUcOR&@jzczonMsdg1}b5RiAowE`vLfRQFgv z2TyJAtAjVgPqctZvqjx6DR!Oclqdv2tPgG<(ybCEL48Z$8#KtNF-J6{2KR+KwRB37 z?*t~z1ztUXx~Z`iZli}s38>nrU9677`g}lbSy20D)J=Ph9T4-03-NF;bYPesb zwgWcr2lEw+!Xhoi;@CEdD{NStSIbb^KQ^iK`V?HKOTN1h7$Y&CefjlzGd@g@gETEx zQOjJS^x=uX+^M zRe*^NhOo}KoHiSJnFec3@VE>x0Pdy2zv?)M9d_xPl`(dI;9jxUoq^PNN;ZWx4BX!- z?bj>jK`N8t53#Q|OLoiDEzR)Qt2waSRBgNQ=}RN(DOOEz4g>e^1PT+odd{cdv@3{p z*s90MZ@%$bXf|aqwnHyhymhR`F7P)Dy|r6^mSv0OsofH}E6zOxs*qQYaVn?2oXl?9 zQ*GcYV0qurv!#kLidB=-?`==;`YF!_JwFN3-vtYp<=w?N!-^A+r{?IVxIt5bZ2PtrEs=w;E_yIuO{ji() zt>1S3K6tK>f1wNOK;MVuT6(P?=+H~ua5G+Se6Rx^Se3O_BkM8)eYsR`X`H1!fF|0R zhLb4Rgk3WCa=a8eccCt|-H+qb(LxhXM;mAgsWu??W(9kwYUjgd`b2V|sUB}wgw6Je zWbIn@xVz#jq$P{JgbsLDGZpq%@+W+#8M=O!$HaBhiwdusUNrUBO_%EHrt9SErt9=B zSoNG0xHlo*6-#!m3=~EA=6d1$rh(F{ruaJ9Ga(biF7c|;KaY8ZeT&b{Df(_kSd@T0 zlTe%XJ&Yl|hp|chTF$+Ty)k_mN!{XD@MICsdl%ukZ}>b7ZCQ|7$p>Z&+#+5~4zRP&(lDf7SkTmaYk6A&%6Cc9%L(C?Yc8e__j2bOj zJYSRtgu-RUTJS49K!EPSR2`lQ^NAIWrV?zB*>O*vy02hm&j4N8H-TsmbGYZptb_6A zK3h+gEqthKAWMbbiH2I&1Vdf!d({o*@mrjw?sFFIR(uAG6QW0GH}$Oz(2JYYE64Nnz|<$s1@a-cM7H?eDSG zsjOg}91~nM>Q)Q;9fik1>87jBfaD#DWqQDls;RU>a6-V|K=hX0spMF<+N!&3139HM zbKIt26VE^4X$qO8P9VQYmi9wUT~kDM*I5WY9>#TRF+7rt-BmoL4$&j99mBl;<-=M8 z9-Nj+PpLuf=*ZWQx!tvdDw228;X-&a0^8HF$=*^5)skIR)D+b^zl_qeA}7u-olxNk zCVZdrow>{Ood(adHUNv*<~x76V-dJI_r;n8fu?Sg?U@ahBQXGBk-A=WrmL~Xdw z^Yc&WG7sk}!xgWBA%ZLHMT7jC-&FnA#MH(Ub7M4O!tw|04Wn&gWxC{5GDvVufJM2< zaYm`!wyO&2Do{0)#;n7@X2<0wdP!oWV=$M@R>Q6IrCQ-q4vUMm`VEP&`8LRjw%q?< z{Fbj7f>=>}JmJ&*)bz23YKC>~0)S8b-&?}91zk$!vBD!B_X14iM4K^({RWRR)0wctgPXsiC` z4h5g9(Nk%sFx{DW>YB)pN z9$3$lEaw@7(Crp927JJWuCH@64Zba z!&UJ8L{m`@yACwJcGgZzumjc9r2A`+SY5o6mwSx_%w97-y(;Ht%l1^o{o8q>_+a>F zkA-vhR4n|vIikc+@K`UP2XCr}ZqAG6;{M)($!$~2Y%8A9q0%+I@iz|7i2{uYrFpUR z*R?F-j}l%~xSJGy<2$bCbuHnL@Vb_Q7qz(jQ{41#3jL(cfrQ~+xPKVm$DV(beK7@4 z!QgU1qjL#tF8e1cTFEabc@>H#)R;9n)=pm_yv72|-SaEH)$now^PZC_t#rXNX4d}C z<`Q^5M{zEh^y%@xjc2SuLf?4d5eg6n`8&v+8h!6JJpCJ9Hm6=sXL>f*E*GCy-%9I9 zOXlM8zvu1aK`uJTb^dQuK&$GE`|W)r`DJJOhCT;wvQVpf_)1OH#D#jFIIn{0fVCL& zYC))uQPHMz1;{l2N zMN2kR7>_mhZjM(k&?eQuNsf=nw~b$E){*O42NkEr6R&{PvkcfE+6V7>WN#&T6`!s% zaqr_gy7zHmV_R%tqthL0bOzx6Qi!;9*abSfeH4#+9#TuT7jxFc^7nf-UJ&!&<{!1L z^-M-Uzg9J>#_;Vjd%kL{UQj=hOgEV#r%rIgt?LS$o;e94Iqt`Es_Avhctd3>6*3R! zjmPMj=Ft?qekYzwXQB(uL038x!-s~GqwsdXMtIB~&VX@37+w+6SHSEUVI#a(GFYry z4v{ZQ7F-Wxwn!x<(-wJHraBsQIJCvx&rM-3jE95a^@^fHODnQ@2)YWY$drZ% zormFqp+*KIKgA`#7ymIvXWTLyt4t`TFOJmV^XS^TUQWjT%c$MuYwwxuN!8Nt+ zQqD52DzF?LIouz^09e-D4KbiMXSs$EsYy`4P&L3ppn6|ab~inZs|FgKH51_kf$!z= z#svk9Lh^8uy^>o#eRO=HJ-OIVUc;O^2e?Fr|y~eof49*D` zUJZip1($=oJSgqyQ5-^e7w!_mH`J?V*34nguYqIr{2KprF8dX~fto`;-13jj*v-rV z?t}Fj#f&$KJQ;A{MA2go2vs7D%rFj?6dx0_Dxn@!7vnj^c~2R4<10Md*qyUr_FFJl z;j=8LNiV0sv&%iaQmS9n^vTO71tyj~mg^va1@aPH-RQuDthIFdwwseAmxta8U z9dG5;@P4jvSiOW$Ela>lC_CXrBkD2a-ct4I_to&!9(Yt6Jg~fCHcW8fiQw=#*o&VF z=FWu&3@4KPxtMxTcs)GPLBFnf_To6a@o#n_+SwVZPDZ0)CmF8}B|EA+!`0DbqN}4m z5^=g}XGe<{sViQjws?_9@ghzQ$|QT@v(+1`;Rw7jJE$kh5_YP~p$1{QCd1|EbIOti zVcQy~gvvmffI+C#ms)R5Xu`NeHW#JlB%Z(GU>K$M^kwzS9|z!N#jf&3%ZQ_TL0vie zq9|NkqpowCPzzcD)&^!Rk}y(E0iBxS2}=;Ec00u>p4d?`CC6$xesbGi=MLPuz|~v_ zS&OXZS~p>&cr7J}I5ox7D3OvYs=9c>5=5LZ(v)hX5-zT1aGY`o%M%W{qo>vtz)pQ_ z86Hu>#d#!(e8q{>xy`OEUP}ogk#c zLY+`K((VF8%CIFGaW%F&)K_HeyS3DoW0evv&V)!gd^K+So$BJnMVjU*jrRP2gEkJ!CD}j%aY6I^-!X?rY02 z&50CGs6aq&0q>J;i6E5Cv$0^H7cd5gnvJ@)m;)?CIp}LHQP>;gJ z84#^@)qP#@T9insAzNEck)25K)+&)wt#xY3F&BhgO|2Y#PIWobO1QXqal+*gD&Zmt zopK1v6AqO_U5;!t>Eg_G!sRf6u&W6aU-ynvjt%7rhsvVPhdJ)Co$Yo;H01UV zV8u3$5=82XXQ@SzQk$R?DV|V?luTFygaI$(Kvp|UtBW@mL~JcixOg=vHdy%P*K12>5Z?PbUCwFr z-QXv8$o&13?*vP)&3`<|OZ;O3|K6a>=6~rDnSX?MH~8?s0P+(5cH(a)c`X0FaBTCZ z24wyaRrHV#e<#RG{8f8v1)P6Bf&U5O2lEQjDDm6=MlPOG>ty@KNd7u3G)uO=PLP-E z?|amv=>J3D*tY-pUfKR< ziQne4?SHvbwtt-DU-q&8MUY3G4OYHc`?>vpIOMVIpV1}T|NH@3;bE3u+y1(b$@Z&Y z1=$0r9T+vXKf)j{+22q0Zze(XKlYin{nKxd?Vo<27PR?n`~G z&B@`|_W$)B<8Ld;cUtnc{U7ldf42+%KVGo^CE~Z-RPi7Y*?hMB?P(AHli*!G_P+z< zCI44Z!{PBiQ?UPM#BbYw>A{-V&;QH&Wcx=*ey3Gn+aF6nUb25H*}sX}KepdD3-*8M zLYaT~joN-c{s%!`;%`4t6LbDk1pc*aWd0#+tdPF1Kk|R=@~;7TiGSA{H2-#z#PXj8 z$9Dhq3h~?Zw}L9}^gOdZu&SftFBf~*Px8OAR|0ZZhtM4}dbfxFNgmHxc(VObcd0DDjpS!>VK}zs4}Om@~8UnUjp({ z{B)E5an6GY;Ug94W_!|ZOU%pr7A1D4AAO4?#yu`ni_(LR*^*>MG zU+lpjs?vgf@wdQ(f0t1H`2zp`*U9B?C;nz_M*gqu|Ghz8Du3l6y8q$xPt;|{-;C=u zzuo_ACVta?i~-2S(~2u)`^TzuE!DiQo4B)x_^- zf9;TL|0v00%<^RW-x-qaj~?cZp9?^d?f>tCJm$3R{~Pi9#s5r?@vn*|j&vFU3_98N zALlXtjgtL_ALHjDP+{AD`Q@_z8UpfP3i49@Zx#68De#Y9EAzJ#zpbi1%5o2GxcfW^6wt~ z&v?M{@ABw>Mg;yof&by#W&c;q)TK7|8Xwr@|02js<=-Ok_Y3?7eoE%A49GtXZk4-voL`QHaXUgEEKmJ6mLD#4 zy2_uvSo6cr1!;`qWKp@$_gXI0%&(}d-vVXJSKj>;)_FoG@E%A>Bh`$+yS+d(t7syNeRm9KR51i_{_;(V& z-G0^+zhC>gqFuIsIH3RD3-Xfvn*{%TP_X}RFe#S&HxiKlFCZ`Rj|%*7s_OFJ8jt-O zqXGGQJoayF75J|a_!oPv-v;Dg0P<4#cL@B$0{@jB2e&-(tgS^B)OW?mj;GZ`r^H-h>nIS!`i=n^3 zU%UT!JIG7?4aC2T!h~Mr%cF@EJ=S(~y7f)}4lX|4E=MU|(bgsob zGaTFYM?qe)KYFA#ocDh>gS>73FNxo_|8C+p^^*2iVztw6Cy(|8X0eQ)P z4a8qX^2mRu!2c&`)Dr*5X_|785C0!PUgB>*S_^I`%aQ*x0{_=G$@TaA=~~c^7rXwx z4Du3x^ccS_HQElx&Q7K?El40viSE;o+T~db@{;}A1pDt3?BD-Z z*?+mWXiC5S3rqGyX3M{I@};f)rq0L7M64uQUwge?u;wJ`012RR66c?|1$3PLP-E zk5;+;_az{<{r8^F%KSsbe-t+sj_vw86FL>i{t=Rg<&}bD%SS+7vcHw=ZzpN2|F6KY zZT~Me%ls8#EqEx&n-81+Cm=8Jj}m`~1d;y{IJWsS&?!mfuOfcG^7n$g#J_|1n;HL@ zz<>Kan%|DUA>yA-^5z4%c)I9wvi-v({|25Jj&1*)2lA5rvyOMi-#0+s_FotHU-I7w z@!R&={4af4mLDbgcl+4?0?14DH<10@|KAkse}jkpaEGr-r~B|{VNxKKzn$cd@saNX zdCC5bWIvDJCqb9(|4Wz4{6oZlfDivVkeB$k2=)JMf&T#zmdZa&{G)pp8erG|y&x~~ zZ!`6`mzKx&`;@@{zr=61pN4uZ*IG!gxHxnv*eBXg@+Vjg$hLnS$V>LmI0>~@RJ2nA zM*FwHcf0=XC4Srf2Nx*u3g})65_0kM<1=OZ+bQsed1g4a?f(JDOZKmKwEd(;t-pOA z;JqX zWd6z}ns%gs-5r0eATRN^690OV$M*9xIJWs;JW}Q#CH^fw{C@;_iGM5coA!hJzYzGl zV1gz2zkjJF-p_}B1N4)U{1D0C?<0R1$V>LORqOg|Cwa90mvC(R@69kkNc;`0TJRe_ z@pl5qOZ;1ie>3BMPT(J}mH8`IXu+pR-h9~c`+Ja=_}goA`L~lGmj5?!Y?puEsOE>C z3(^ka_d9>O$|L?N&eQ5(dQy;V`!{&RUo_&bzu$r;+x{~Tmi;$M{9m;SX7fitUh>~2 z;t!Ga=)d0!{BH$?QvCF{X~BQ!VvrVh`>zFgiGQn5{yzx(Ke@nz{~}F!hYx?pL9#rq zi(&es{>cBe{dY0QOZLyI)xC{{f%)nE~<=e{-EKe>>xUQQ-d_46qXa2nl|al6o%a$zgKwJ-%9e2PKc@_+y3`@*gq|*nR)%a3>xhEYrasnzp6tM zA7T~2=Km_3pkS7LboTt;hJ-$RE|2H~TYd}3Oa31c?Ef36u2=QOz!+$BrOXc5KuN@y^{OD*``OlX5LrE<-(}({hkeB$k z2>h=T_$%kg{O!a~%RqT6?D{_djRsA8puohGtLAu zNZkMX2>gGVEAww7{xg)8i9a^~A3$E>Zx;CRmTbHHE9c4l69NFnuC??1s?3T^+r+$i%86aNg7Hy<|tiy$xYcMJS@ON-5a z^)i`%l=yEj#Pn(NUkUOO|0aPSxAfcm8IShU+N}x0KK#8N?PsgNf4IPZ(A(tl4-@}= zKK%QGyj1>O#NSTx7{9os!Y=<6#Bay%yp$HS{f%5at$nL(e>=(h*}n$lCHvdX0%IYy ztG_yaj}+|xAL6&|KWvp2^s~QWxom%kUHOl;>#1GA@ zAlda<2l5jCu)u$!z`q05St*^{3n3C#J^qOf3v{9>2#UDf%r$jZl3J&UkCCM|2XmU_&-J9zv>j3e@CBYJ%E$L zvCY2$LrTlUADjPeATRL`5q~p@V*Q^X@c)JQEjL$O zn%06gA9C@u7ABZjQagT!N#53L>$?gj$dddB$#-%xIJV_CfV@=xtyKOH$z%C(N!yP9 z-_Ml!EBm!zoe%$SKwjeCCGg{tp3VO~Xy8)$TZ#WrAO5F6UgDqicGOxJf4C%M^Ur^a z%-E-Bdj>t@OP!^D5EPx&(-FY&ht{CG-b^FMH+%s)!}RX+Upg1p4v zPy8X0$N0l3kMOG;vW&pk6oh8|I$*Ke+TjJ>%-sWAwN#?2l>dy zL0+=!a z^&cYp`S{%|*uMb=XtdEDe;Tryct8D-|7-JCoh-|@k~~~qC`h*aQ6Mkbzgh4J-%tE-dQp&U{zi}Xzn%D-NgC_FMc}`6v0VPqfd2dH39|ec$sb!_y*qxkfV||t zsySGzh2vMNVELW1Qak>Ja+=tW{}Paw_(ui) z6$1YQ9`Rc>p!q-IQ~rBB;&&Eyc93}a&lmXr@MgLEqr~r5{+%E%m48IwZxi?v9{jCq zb;14k-{rwSW3IdW7YY1dgzN8^)2{z<;{TI2BmdWKzxRQ>RQ_S&A0|m`zwZ?IlQkav z>$IR>|Mk>SvV0{~Jh-PI+4gS*dCC4N+ITvHq|yGEVE+*w{Z9k&`^_H?0eOi(O8g*5BE!%3h3ZLtt} z_Fn=SB>7>I_ZvS?t(WCTNPaIL{x8D%fy6&b@_zMyAIMAo-z4}yCHQ~o5}ALD`1kd( ze;G{BCHuFLyczF}8r%OZATQa!RcJpwg8k2cgCzbP0sTMCWBxZz^3}$0`n2ue3*;sH zcL?^U1^d5$ylj8y5={u|3zE(MG{{T*y9E9<0{;W^W&UB}Uv34k&3`Y*OZ=5M*@iTx z{_6GPtiZpbN#-Aaj~4WcpPlE+@)hq5CjTptm+aq0_RnDUuNCZHG*jlUB!0j3vm;@G zh@P^?&nl8XSG$p{aL*qP2YJc1JJ;|F7oBwQ(m-wp|XntP*?-lsJ zdAQ8KjriYZi0RYje-z{;{wVRA@ej*CB=D~e$^7k?YeH~uL9+R~Kwjb>70UlUf&V`| z){lpXA4Ce0&HtuFvi-v(e_DaGJAMuWdCC5*g8lCo>_4MQ<{u+|m|hkn+x}S~FY)gX z_^%ZB4}bv%U|0LEpumCCvw~#v&-UoQLnOZ!Drd=-KgFZ}-bMEF^XCr=_CMz_{x@8q zNgwwaKYrmc{x{%a2c&VWM(rQJTHycDJlTJhS874O`P&CQ;;$;8{Hiy}{2`Lxo9s6q zw*RM}Bg;3CykGl$-9lNuo#YQN#Pn(NfAv&ZzMtf;@sZyG@>2YV7V7%r{nvFu{e2P| zIOc@ztuSrWU*YmhK|(H`Ui9#P70LVg|HB^s50Si||2KH}zcrxzobx>VAJG08%VhZ> zlAq3<1;=*%9}4nP{cWcDLj?ounm_Cy{zX3gFF*$=$%n4ig0MVSkZk*(2YJcF2)x6ZqfwfaX7h@|lkY z;zxnZGr!AihsNOn*#Dzg1l7z9RmMT0{`=0*8aEK-zf3VG8Ijq zSXVs#=pniOwgaWKeqqx3H%LD%l@18 zaqT5IJuXPL|9Y=0Zc8-An2v|IE2hafN6KTELxhl2f|cueN+56FKD$V>bU0{@Q$ z{^766{2K%Ee+c9y{x*UC#{&NvkN6)U{=JQ(>C>*i)ep(_e<#WB=Oe#AjURUVyO-qm z^^qUBPxjvylDGSRJAQ5kdC7nMg8zOl_-`Y{uRZ>3CH^4~`>P1Q_FJ<5nm6eTe&s(D z;t%V@?mt^e-mm?<0pz9fZxqV^OQHPtjLG(Q6aNuZPV-^M&jB9sKNQgZeLUiSM6mzY zg8gTE#Q!GZzt9*?pSJyf{i0m{7f9ZYYg_(D56bd?CV6}P%9cOLBmOIH)CSM6?6>2; z$|L@_2>$z>;J<61lI@>C{85YFw*Oro@!v}Fe&g>E)c$NguP6CaeE4tuhHU>QNdC<} z@@I|7@>@wh>?8mEugLO$C3(0!RFG`{J@qA7{*0Tn*l7jQIxmN=^N8OzlK1mpmq+|w z9+3Y_qq6;Xle~TYV3)tjBmSNxdHcG$E&szOHNV||?j(7?_OlJ-rT%l9(0={`L`6zu=2VE?zjCfh%Y_}^pB)=aghhxKU;9IHv*zz9_$p6Q$%kgttK>1dW z{_{?fhwU*1$+mxmNBw_; z-}rffNBu1#c|ZT1N&T1Ie)~zj%Cg_|e+G_#ywrYYQ2RCgKepfL@ZZRrc72ye{0tMn z-}u)}?a#J2K-!Ay?V1fU5kNB^+RoBp| zrlRQ6Za-h8__ys3k-VuVqsG*q`Mw3@CHsd3{~adSU**w$nu&jbA@Z>QIEsJU{^5Z3 z9|`i3{hI~*j}Yu%^L4rYHwWbJ0eOjkOyGZ$z<=Dsn%^FO#)!Yt@}H?cga3AmZ2z`^ z_CE*mlKtBS`;Qjv@AZhkodNl~J>qX%;6GO2Z}Yf*q+S*PzVnO!*u@`EwvI@ec|7rwaV<_h|pa0r`hK z+W#hj|8#->XZOqg-yD#CJIG7r9~Jm#3;a)etbdOY|Ao34=KtE`=Z$dv2r*Dyklu8= z{tDBhf@I6zuvM1-1bEy5={F_I#n7x10mQOw^^MCFWTK;^d)p+8>eS z-}6bG>&rYd9NYX26#usUb)O0*e-Op5E&l?^A7v3*Un^QY)~~1Esri5AWB(wwtou*C}MW~{f^{+l9$v6w)|}m$?}hVPUrur zkNmjD`tQiyTF`I)@q3T;->pLb*9e6)^FO2aSz5of$Db_$`F{lRQvbU{;GZY(Cn$bw z{%ryI-v#m#|1N=lzQDh0OxK^yzmxc@tok$lGw`25UgEE$8-I+SasJmV@L%8&ztir~ z3nrz19_?ZZ36#NfqyA<@K`6d|Jwudp9As|f4{(guE77pZ^-=X1M+VJd5M3cz`tDJ z&yC6a!vXozATRNc2>j;>{CiXXZTo+7K>iAlm-x2`{1*uP-}G2N9wYw!O~a#4yZzoq z*I#V=w~>66C2zOi8$n*Of16N~de~cAO=5rr7R{byh z#?uhlZ?_lwJIX3umVC2d|4PVX;^c6=k@(NBaLkLzeEtv$V2<@K`tKJ= zo@01y=dFhh2C#n1{E96k-+XwX24lZLYd{{$-(UdxY^U;%&;}6n|7xN9`#}F;bEAsN Q$F!nN4f+%9zC@7!e`p0l)c^nh 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 d682a28..2f3192a 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/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/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/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/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/.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 457dbe2..ffed241 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/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/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/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/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/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/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/.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/tests/api_client.rs b/src-tauri/tests/api_client.rs new file mode 100644 index 0000000..35a09e7 --- /dev/null +++ b/src-tauri/tests/api_client.rs @@ -0,0 +1,213 @@ +//! Integration tests for the Proxmox API client core. +//! +//! These tests exercise the generic `api_request` helper against a local HTTP +//! mock server, covering auth header injection, response envelope unwrapping, +//! error mapping, query parameters, and URL construction. + +use httpmock::prelude::*; +use proxmox_desktop::{api_request, AuthContext, AuthMode, Error}; +use reqwest::Client; +use reqwest::Method as RMethod; + +const TICKET: &str = "PVE:root@pam:abc123"; +const CSRF_TOKEN: &str = "csrf-token-123"; + +fn token_auth() -> AuthContext { + AuthContext { + mode: AuthMode::Token, + token: Some("root@pam!test-token".to_string()), + ticket: None, + csrf_token: None, + } +} + +fn password_auth() -> AuthContext { + AuthContext { + mode: AuthMode::Password, + token: None, + ticket: Some(TICKET.to_string()), + csrf_token: Some(CSRF_TOKEN.to_string()), + } +} + +#[tokio::test] +async fn token_auth_sends_authorization_header_and_unwraps_envelope() { + let server = MockServer::start(); + let expected = serde_json::json!({ + "version": "8.1.0", + "release": "8.1", + "repoid": "abc123", + }); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/version") + .header("Authorization", "PVEAPIToken=root@pam!test-token"); + then.status(200) + .header("content-type", "application/json") + .body(serde_json::json!({ "data": expected }).to_string()); + }); + + let result = api_request( + &Client::new(), + &server.base_url(), + RMethod::GET, + "/version", + &token_auth(), + &[], + None, + ) + .await + .expect("request should succeed"); + + assert_eq!(result, expected); + mock.assert(); +} + +#[tokio::test] +async fn password_auth_sends_cookie_header_on_get() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/version") + .header("Cookie", format!("PVEAuthCookie={}", TICKET)); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"version":"8.1.0"}}"#); + }); + + let result = api_request( + &Client::new(), + &server.base_url(), + RMethod::GET, + "/version", + &password_auth(), + &[], + None, + ) + .await + .expect("request should succeed"); + + assert_eq!(result["version"], "8.1.0"); + mock.assert(); +} + +#[tokio::test] +async fn password_auth_sends_cookie_and_csrf_headers_on_post() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve/qemu/100/status/start") + .header("Cookie", format!("PVEAuthCookie={}", TICKET)) + .header("CSRFPreventionToken", CSRF_TOKEN); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let result = api_request( + &Client::new(), + &server.base_url(), + RMethod::POST, + "/nodes/pve/qemu/100/status/start", + &password_auth(), + &[], + None, + ) + .await + .expect("request should succeed"); + + assert_eq!(result, serde_json::Value::Null); + mock.assert(); +} + +#[tokio::test] +async fn server_error_message_is_mapped_to_api_error() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api2/json/version"); + then.status(500) + .header("content-type", "application/json") + .body(r#"{"message":"boom"}"#); + }); + + let error = api_request( + &Client::new(), + &server.base_url(), + RMethod::GET, + "/version", + &token_auth(), + &[], + None, + ) + .await + .expect_err("request should fail"); + + assert!( + matches!(error, Error::ApiError(ref message) if message == "boom"), + "expected ApiError with message 'boom', got: {}", + error + ); + mock.assert(); +} + +#[tokio::test] +async fn query_params_are_included_in_request() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes/pve/qemu") + .query_param("node", "pve") + .query_param("vmid", "100") + .query_param("include-config", "true"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[]}"#); + }); + + let query = [ + ("node", "pve".to_string()), + ("vmid", "100".to_string()), + ("include-config", "true".to_string()), + ]; + let result = api_request( + &Client::new(), + &server.base_url(), + RMethod::GET, + "/nodes/pve/qemu", + &token_auth(), + &query, + None, + ) + .await + .expect("request should succeed"); + + assert_eq!(result, serde_json::json!([])); + mock.assert(); +} + +#[tokio::test] +async fn base_url_trailing_slash_is_stripped() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api2/json/version"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"version":"8.1.0"}}"#); + }); + + let base_url = format!("{}/", server.base_url()); + let result = api_request( + &Client::new(), + &base_url, + RMethod::GET, + "/version", + &token_auth(), + &[], + None, + ) + .await + .expect("request should succeed"); + + assert_eq!(result["version"], "8.1.0"); + mock.assert(); +} diff --git a/src-tauri/tests/api_commands.rs b/src-tauri/tests/api_commands.rs new file mode 100644 index 0000000..9130d39 --- /dev/null +++ b/src-tauri/tests/api_commands.rs @@ -0,0 +1,570 @@ +//! Integration tests for the real Proxmox data-read API calls and the +//! VM/container lifecycle operations. +//! +//! Every request goes through `Connection::request`, so these tests cover URL +//! construction, query parameters, auth header injection, response envelope +//! unwrapping, and mapping into the public serde structs. Token-mode +//! connections resolve the token from the config, so the read methods can be +//! called directly on an added connection without `connect()`. + +use httpmock::prelude::*; +use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error}; + +/// Builds a `ConnectionManager` with a single token-mode connection whose +/// primary endpoint points at the mock server. `node` pins the storage node. +async fn setup_manager( + url: &str, + token: &str, + node: Option<&str>, +) -> (ConnectionManager, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn".to_string(), + name: "conn".to_string(), + primary: EndpointConfig { + url: url.to_string(), + node: node.map(str::to_string), + token: Some(token.to_string()), + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + (manager, dir) +} + +#[tokio::test] +async fn get_nodes_maps_cluster_resources() { + let server = MockServer::start(); + let token = "root@pam!nodes-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"node": "pve1", "status": "online", "cpu": 0.12, "maxcpu": 16, + "mem": 8589934592u64, "maxmem": 68719476736u64, "disk": 214748364800u64, + "maxdisk": 858993459200u64, "uptime": 123456, "level": "", + "id": "node/pve1", "type": "node"}, + {"node": "pve2", "status": "offline", "cpu": 0.0, "maxcpu": 8, + "mem": 0, "maxmem": 34359738368u64, "disk": 0, + "maxdisk": 536870912000u64, "uptime": 0, "level": "", + "id": "node/pve2", "type": "node"} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let nodes = manager + .get_nodes("conn") + .await + .expect("nodes should be fetched"); + + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].node, "pve1"); + assert_eq!(nodes[0].status, "online"); + assert!((nodes[0].cpu - 0.12).abs() < 1e-9); + assert_eq!(nodes[0].maxcpu, 16); + assert_eq!(nodes[0].mem, 8589934592u64); + assert_eq!(nodes[0].maxmem, 68719476736u64); + assert_eq!(nodes[0].disk, 214748364800u64); + assert_eq!(nodes[0].maxdisk, 858993459200u64); + assert_eq!(nodes[0].uptime, 123456); + assert_eq!(nodes[0].id, "node/pve1"); + assert_eq!(nodes[0].r#type, "node"); + assert_eq!(nodes[1].node, "pve2"); + assert_eq!(nodes[1].status, "offline"); + mock.assert(); +} + +#[tokio::test] +async fn get_vms_uses_cluster_resources() { + let server = MockServer::start(); + let token = "root@pam!vms-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/cluster/resources") + .query_param("type", "vm") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"vmid": 100, "name": "web01", "type": "qemu", "status": "running", + "node": "pve1", "cpu": 0.03, "cpus": 2, "maxcpu": 2, + "mem": 2147483648u64, "maxmem": 4294967296u64, "disk": 107374182400u64, + "maxdisk": 34359738368u64, "uptime": 99999, "netin": 123, "netout": 456, + "diskread": 789, "diskwrite": 101112, "template": 0, "tags": "prod", + "pid": 1234}, + // A stopped container: the API omits runtime stats. + {"vmid": 201, "name": "ct01", "type": "lxc", "status": "stopped", + "node": "pve2", "template": 1, "tags": ""} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let vms = manager + .get_vms("conn") + .await + .expect("VMs should be fetched"); + + assert_eq!(vms.len(), 2); + assert_eq!(vms[0].vmid, 100); + assert_eq!(vms[0].name.as_deref(), Some("web01")); + assert_eq!(vms[0].r#type, "qemu"); + assert_eq!(vms[0].status, "running"); + assert_eq!(vms[0].node, "pve1"); + assert_eq!(vms[0].uptime, 99999); + assert_eq!(vms[0].netin, 123); + assert_eq!(vms[0].netout, 456); + assert_eq!(vms[0].diskread, 789); + assert_eq!(vms[0].diskwrite, 101112); + assert_eq!(vms[0].tags.as_deref(), Some("prod")); + assert_eq!(vms[0].pid, Some(1234)); + // Tolerance: fields omitted for the stopped LXC default instead of failing. + assert_eq!(vms[1].r#type, "lxc"); + assert_eq!(vms[1].status, "stopped"); + assert_eq!(vms[1].uptime, 0); + assert_eq!(vms[1].cpu, 0.0); + assert_eq!(vms[1].netin, 0); + assert_eq!(vms[1].pid, None); + mock.assert(); +} + +#[tokio::test] +async fn get_storage_maps_cluster_resources() { + let server = MockServer::start(); + let token = "root@pam!storage-token"; + let 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": "iso,vztmpl", "enabled": 1, "shared": 0, "active": 1, + "total": 858993459200u64, "used": 429496729600u64, "avail": 429496729600u64, + "status": "available"}, + {"storage": "backup", "node": "pve1", "type": "nfs", + "content": "backup", "enabled": 1, "shared": 1, "active": 1, + "total": 1717986918400u64, "used": 644245094400u64, + "avail": 1073741824000u64, "status": "available"} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let storages = manager + .get_storage("conn") + .await + .expect("storage should be fetched"); + + assert_eq!(storages.len(), 2); + 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); + assert_eq!(storages[0].used, 429496729600u64); + assert_eq!(storages[0].total, 858993459200u64); + assert_eq!(storages[0].avail, 429496729600u64); + assert_eq!(storages[0].node, "pve1"); + assert_eq!(storages[1].shared, 1); + mock.assert(); +} + +#[tokio::test] +async fn get_storage_content_uses_configured_node() { + let server = MockServer::start(); + let token = "root@pam!content-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes/pve1/storage/local/content"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"volid": "local:iso/debian-12.iso", "content": "iso", + "ctime": 1700000000, "format": "iso", "size": 68719476736u64}, + {"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl", + "ctime": 1700000001} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await; + let contents = manager + .get_storage_content("conn", "local") + .await + .expect("content should be fetched"); + + assert_eq!(contents.len(), 2); + assert_eq!(contents[0].volid, "local:iso/debian-12.iso"); + assert_eq!(contents[0].content, "iso"); + assert_eq!(contents[0].format.as_deref(), Some("iso")); + assert_eq!(contents[0].size, Some(68719476736u64)); + // Tolerance: optional metadata is omitted for the second entry. + assert_eq!(contents[1].format, None); + assert_eq!(contents[1].subtype, None); + mock.assert(); +} + +#[tokio::test] +async fn get_storage_content_falls_back_to_online_node() { + let server = MockServer::start(); + let token = "root@pam!fallback-token"; + let nodes_mock = server.mock(|when, then| { + when.method(GET).path("/api2/json/nodes"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"node": "pve1", "status": "online", "cpu": 0.0, "maxcpu": 8, + "mem": 0, "maxmem": 34359738368u64, "disk": 0, + "maxdisk": 536870912000u64, "uptime": 0, "level": "", + "id": "node/pve1", "type": "node"}, + {"node": "pve2", "status": "offline", "cpu": 0.0, "maxcpu": 8, + "mem": 0, "maxmem": 34359738368u64, "disk": 0, + "maxdisk": 536870912000u64, "uptime": 0, "level": "", + "id": "node/pve2", "type": "node"} + ] + }) + .to_string(), + ); + }); + let content_mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes/pve1/storage/local/content"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [{"volid": "local:iso/debian.iso", "content": "iso", + "ctime": 1700000000}] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let contents = manager + .get_storage_content("conn", "local") + .await + .expect("content should be fetched"); + + assert_eq!(contents.len(), 1); + assert_eq!(contents[0].volid, "local:iso/debian.iso"); + nodes_mock.assert(); + content_mock.assert(); +} + +#[tokio::test] +async fn get_storage_detail_maps_status() { + let server = MockServer::start(); + let token = "root@pam!detail-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes/pve1/storage/local/status"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": {"storage": "local", "type": "dir", "content": "iso,vztmpl", + "active": 1, "enabled": 1, "shared": 0, "used": 429496729600u64, + "total": 858993459200u64, "avail": 429496729600u64, "node": "pve1"} + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let detail = manager + .get_storage_detail("conn", "pve1", "local") + .await + .expect("detail should be fetched"); + + assert_eq!(detail.storage, "local"); + assert_eq!(detail.r#type, "dir"); + assert_eq!(detail.content, "iso,vztmpl"); + assert_eq!(detail.active, 1); + assert_eq!(detail.enabled, 1); + assert_eq!(detail.used, 429496729600u64); + assert_eq!(detail.total, 858993459200u64); + assert_eq!(detail.avail, 429496729600u64); + assert_eq!(detail.node, "pve1"); + mock.assert(); +} + +#[tokio::test] +async fn get_tasks_maps_cluster_tasks() { + let server = MockServer::start(); + let token = "root@pam!tasks-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/cluster/tasks") + .query_param("limit", "50"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"upid": "UPID:pve1:00000001:00000001:5F000000:qmpstart:100:root@pam:", + "node": "pve1", "pid": 1000, "pstart": 100, "starttime": 1700000000, + "type": "qmpstart", "id": "100", "user": "root@pam", + "status": "stopped", "endtime": 1700000100, "exitstatus": "OK"}, + {"upid": "UPID:pve2:00000002:00000002:5F000001:vzstart:201:root@pam:", + "node": "pve2", "pid": 2000, "pstart": 200, "starttime": 1700000001, + "type": "vzstart", "id": "201", "user": "root@pam"} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let tasks = manager + .get_tasks("conn") + .await + .expect("tasks should be fetched"); + + assert_eq!(tasks.len(), 2); + assert_eq!( + tasks[0].upid, + "UPID:pve1:00000001:00000001:5F000000:qmpstart:100:root@pam:" + ); + assert_eq!(tasks[0].node, "pve1"); + assert_eq!(tasks[0].pid, 1000); + assert_eq!(tasks[0].pstart, 100); + assert_eq!(tasks[0].starttime, 1700000000); + assert_eq!(tasks[0].r#type, "qmpstart"); + assert_eq!(tasks[0].id, "100"); + assert_eq!(tasks[0].user, "root@pam"); + assert_eq!(tasks[0].endtime, Some(1700000100)); + assert_eq!(tasks[0].status.as_deref(), Some("stopped")); + assert_eq!(tasks[0].exitstatus.as_deref(), Some("OK")); + // Tolerance: the second task has not finished yet. + assert_eq!(tasks[1].endtime, None); + assert_eq!(tasks[1].status, None); + assert_eq!(tasks[1].exitstatus, None); + mock.assert(); +} + +#[tokio::test] +async fn get_cluster_status_builds_cluster() { + let server = MockServer::start(); + let token = "root@pam!cluster-token"; + let mock = server.mock(|when, then| { + when.method(GET).path("/api2/json/cluster/status"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"type": "cluster", "id": "cluster", "name": "proxmox-cluster", + "nodes": 2}, + {"type": "node", "id": "node/pve1", "nodeid": 1, "online": 1, + "local": 1, "ip": "10.0.0.1"}, + {"type": "node", "id": "node/pve2", "nodeid": 2, "online": 0, + "ip": "10.0.0.2"} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let status = manager + .get_cluster_status("conn") + .await + .expect("cluster status should be fetched"); + + assert_eq!(status.r#type, "cluster"); + assert_eq!(status.name, "proxmox-cluster"); + assert_eq!(status.id, "cluster"); + let nodes = status.nodes.expect("nodes should be present"); + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].name, "pve1"); + assert_eq!(nodes[0].nodeid, 1); + assert_eq!(nodes[0].online, 1); + assert_eq!(nodes[0].local, Some(1)); + assert_eq!(nodes[0].ip.as_deref(), Some("10.0.0.1")); + assert_eq!(nodes[1].name, "pve2"); + assert_eq!(nodes[1].online, 0); + assert_eq!(nodes[1].local, None); + mock.assert(); +} + +#[tokio::test] +async fn get_cluster_status_falls_back_to_default_name() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api2/json/cluster/status"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [{"type": "node", "id": "node/pve1", "nodeid": 1, "online": 1}] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!default-token", None).await; + let status = manager + .get_cluster_status("conn") + .await + .expect("status should be fetched"); + + assert_eq!(status.name, "default"); + assert_eq!(status.id, ""); + let nodes = status.nodes.expect("nodes should be present"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].name, "pve1"); + mock.assert(); +} + +#[tokio::test] +async fn lifecycle_uses_qemu_or_lxc_path() { + let server = MockServer::start(); + let token = "root@pam!lifecycle-token"; + let start_qemu = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/status/start") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + let start_lxc = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/lxc/201/status/start"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + let stop_qemu = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/status/stop"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + + manager + .start_vm("conn", "pve1", 100, "qemu") + .await + .expect("qemu start should succeed"); + manager + .start_vm("conn", "pve1", 201, "lxc") + .await + .expect("lxc start should succeed"); + manager + .stop_vm("conn", "pve1", 100, "qemu") + .await + .expect("qemu stop should succeed"); + + start_qemu.assert(); + start_lxc.assert(); + stop_qemu.assert(); +} + +#[tokio::test] +async fn lifecycle_with_invalid_type_errors() { + let server = MockServer::start(); + let probe = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/kvm/100/status/start"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await; + let error = manager + .start_vm("conn", "pve1", 100, "kvm") + .await + .expect_err("invalid vm type must be rejected"); + + assert!( + matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")), + "expected InvalidUrl mentioning 'kvm', got: {}", + error + ); + assert_eq!( + probe.hits(), + 0, + "no HTTP request should be made for an invalid vm type" + ); +} + +#[tokio::test] +async fn read_methods_error_with_connection_not_found() { + let server = MockServer::start(); + let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!missing-token", None).await; + + let error = manager + .get_nodes("does-not-exist") + .await + .expect_err("unknown connection must error"); + assert!( + matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"), + "expected ConnectionNotFound, got: {}", + error + ); + + let error = manager + .get_vms("does-not-exist") + .await + .expect_err("unknown connection must error"); + assert!( + matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"), + "expected ConnectionNotFound, got: {}", + error + ); + + let error = manager + .start_vm("does-not-exist", "pve1", 100, "qemu") + .await + .expect_err("unknown connection must error"); + assert!( + matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"), + "expected ConnectionNotFound, got: {}", + error + ); +} diff --git a/src-tauri/tests/api_console.rs b/src-tauri/tests/api_console.rs new file mode 100644 index 0000000..41b405a --- /dev/null +++ b/src-tauri/tests/api_console.rs @@ -0,0 +1,168 @@ +//! Integration tests for the console proxy API calls (VNC + terminal) and +//! the WebSocket base URL derivation. +//! +//! The proxy calls go through `Connection::request`, so these tests cover URL +//! construction, auth header injection, response envelope unwrapping, and +//! mapping into the public serde structs. `get_websocket_url` only rewrites +//! the stored endpoint URL, so it never hits the network. Token-mode +//! connections resolve the token from the config, so the methods can be called +//! directly on an added connection without `connect()`. + +use httpmock::prelude::*; +use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error}; + +/// Builds a `ConnectionManager` with a single token-mode connection whose +/// primary endpoint points at `url`. +async fn setup_manager(url: &str, token: &str) -> (ConnectionManager, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn".to_string(), + name: "conn".to_string(), + primary: EndpointConfig { + url: url.to_string(), + node: None, + token: Some(token.to_string()), + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + (manager, dir) +} + +#[tokio::test] +async fn create_vnc_proxy_maps_response() { + let server = MockServer::start(); + let token = "root@pam!vnc-token"; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/vncproxy") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": { + "ticket": "PVE:vnc:abc", + "port": 6000, + "cert": "MIIB..." + } + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token).await; + let proxy = manager + .create_vnc_proxy("conn", "pve1", 100) + .await + .expect("vnc proxy should be created"); + + assert_eq!(proxy.ticket, "PVE:vnc:abc"); + assert_eq!(proxy.port, 6000); + assert_eq!(proxy.cert, "MIIB..."); + mock.assert(); +} + +#[tokio::test] +async fn create_term_proxy_maps_response() { + let server = MockServer::start(); + let token = "root@pam!term-token"; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/lxc/201/termproxy") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": { + "ticket": "PVE:term:xyz", + "port": 6100 + } + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token).await; + let proxy = manager + .create_term_proxy("conn", "pve1", 201) + .await + .expect("term proxy should be created"); + + assert_eq!(proxy.ticket, "PVE:term:xyz"); + assert_eq!(proxy.port, 6100); + mock.assert(); +} + +#[tokio::test] +async fn get_websocket_url_converts_https_to_wss() { + // Explicit port on https. + let (manager, _dir) = setup_manager("https://192.168.1.10:8006", "root@pam!ws-token").await; + let url = manager + .get_websocket_url("conn", "pve1") + .await + .expect("websocket url should be derived"); + assert_eq!(url, "wss://192.168.1.10:8006"); + + // No explicit port: the default wss port applies, so none is emitted. + let (manager, _dir) = setup_manager("https://pve.lan", "root@pam!ws-token").await; + let url = manager + .get_websocket_url("conn", "pve1") + .await + .expect("websocket url should be derived"); + assert_eq!(url, "wss://pve.lan"); + + // Plain http maps to ws. + let (manager, _dir) = setup_manager("http://10.0.0.5:8006", "root@pam!ws-token").await; + let url = manager + .get_websocket_url("conn", "pve1") + .await + .expect("websocket url should be derived"); + assert_eq!(url, "ws://10.0.0.5:8006"); +} + +#[tokio::test] +async fn get_websocket_url_errors_on_invalid_scheme() { + let (manager, _dir) = setup_manager("ftp://host", "root@pam!ws-token").await; + let error = manager + .get_websocket_url("conn", "pve1") + .await + .expect_err("non-http(s) scheme must be rejected"); + assert!( + matches!(error, Error::InvalidUrl(ref message) if message.contains("ftp")), + "expected InvalidUrl mentioning 'ftp', got: {}", + error + ); +} + +#[tokio::test] +async fn missing_connection_errors() { + let server = MockServer::start(); + let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!missing-token").await; + + let error = manager + .create_vnc_proxy("does-not-exist", "pve1", 100) + .await + .expect_err("unknown connection must error"); + assert!( + matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"), + "expected ConnectionNotFound, got: {}", + error + ); +} diff --git a/src-tauri/tests/api_disk_network.rs b/src-tauri/tests/api_disk_network.rs new file mode 100644 index 0000000..150a886 --- /dev/null +++ b/src-tauri/tests/api_disk_network.rs @@ -0,0 +1,465 @@ +//! Integration tests for VM/container disk and network interface management. +//! +//! Every request goes through `Connection::request`, so these tests cover URL +//! construction (including the `qemu`/`lxc` path segment), form encoding, +//! config parsing, and slot allocation. Token-mode connections resolve the +//! token from the config, so the methods can be called directly on an added +//! connection without `connect()`. +//! +//! The form bodies are `application/x-www-form-urlencoded`, so values with +//! special characters (`:`, `=`, `,`) appear percent-encoded in the raw body; +//! `body_includes` assertions use the encoded form (e.g. `:` becomes `%3A`). + +use httpmock::prelude::*; +use proxmox_desktop::{ + AddDiskConfig, AddNICConfig, ConnectionConfig, ConnectionManager, EditNICConfig, + EndpointConfig, Error, +}; + +/// Builds a `ConnectionManager` with a single token-mode connection whose +/// primary endpoint points at the mock server. +async fn setup_manager( + url: &str, + token: &str, + node: Option<&str>, +) -> (ConnectionManager, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn".to_string(), + name: "conn".to_string(), + primary: EndpointConfig { + url: url.to_string(), + node: node.map(str::to_string), + token: Some(token.to_string()), + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + (manager, dir) +} + +#[tokio::test] +async fn get_disks_parses_config_strings() { + let server = MockServer::start(); + let token = "root@pam!disks-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes/pve1/qemu/100/config") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": { + "scsi0": "local-lvm:vm-100-disk-0,size=32G,format=qcow2", + "scsi1": "local-lvm:vm-100-disk-1,size=50G", + "net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0", + "memory": 2048 + } + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let disks = manager + .get_disks("conn", "pve1", 100, "qemu") + .await + .expect("disks should be fetched"); + + assert_eq!(disks.len(), 2); + assert_eq!(disks[0].device, "scsi0"); + assert_eq!(disks[0].storage, "local-lvm"); + assert_eq!(disks[0].size, 34359738368); + assert_eq!(disks[0].format, "qcow2"); + assert_eq!(disks[0].usage, None); + // The second disk has no size/format attributes: size is 0, format empty. + assert_eq!(disks[1].device, "scsi1"); + assert_eq!(disks[1].storage, "local-lvm"); + assert_eq!(disks[1].size, 53687091200); + assert_eq!(disks[1].format, ""); + mock.assert(); +} + +#[tokio::test] +async fn add_disk_picks_free_slot_and_posts() { + let server = MockServer::start(); + let token = "root@pam!add-disk-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": {"scsi0": "local-lvm:vm-100-disk-0,size=32G"} + }) + .to_string(), + ); + }); + let post_mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/config") + // urlencoded: the `:` in `local-lvm:64G` becomes `%3A`. + .body_includes("scsi1=local-lvm%3A64G"); + 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 = AddDiskConfig { + storage: "local-lvm".to_string(), + size: 68719476736, + bus_type: "scsi".to_string(), + }; + manager + .add_disk("conn", "pve1", 100, "qemu", config) + .await + .expect("disk should be added"); + + get_mock.assert(); + post_mock.assert(); +} + +#[tokio::test] +async fn resize_disk_posts_resize() { + let server = MockServer::start(); + let token = "root@pam!resize-token"; + let mock = server.mock(|when, then| { + when.method(PUT) + .path("/api2/json/nodes/pve1/qemu/100/resize") + .body_includes("disk=scsi0") + .body_includes("size=50G"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .resize_disk("conn", "pve1", 100, "qemu", "scsi0", 53687091200) + .await + .expect("resize should succeed"); + + mock.assert(); +} + +#[tokio::test] +async fn remove_disk_posts_delete() { + let server = MockServer::start(); + let token = "root@pam!remove-disk-token"; + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/api2/json/nodes/pve1/qemu/100/config") + .body_includes("delete=scsi0"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .remove_disk("conn", "pve1", 100, "qemu", "scsi0") + .await + .expect("disk should be removed"); + + mock.assert(); +} + +#[tokio::test] +async fn move_disk_posts_move_disk() { + let server = MockServer::start(); + let token = "root@pam!move-disk-token"; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/move_disk") + .body_includes("disk=scsi0") + .body_includes("storage=local-zfs"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .move_disk("conn", "pve1", 100, "qemu", "scsi0", "local-zfs") + .await + .expect("disk should be moved"); + + mock.assert(); +} + +#[tokio::test] +async fn get_network_interfaces_parses_nets() { + let server = MockServer::start(); + let token = "root@pam!nets-token"; + let 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=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1,link_down=1", + "net1": "e1000=11:22:33:44:55:66,bridge=vmbr1" + } + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let nics = manager + .get_network_interfaces("conn", "pve1", 100, "qemu") + .await + .expect("nics should be fetched"); + + assert_eq!(nics.len(), 2); + assert_eq!(nics[0].name, "net0"); + assert_eq!(nics[0].model, "virtio"); + assert_eq!(nics[0].macaddr, "BC:24:11:AA:BB:CC"); + assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0")); + assert_eq!(nics[0].tag, Some(10)); + assert_eq!(nics[0].firewall, Some(1)); + assert_eq!(nics[0].link_down, Some(1)); + // The second NIC has no optional attributes. + assert_eq!(nics[1].name, "net1"); + assert_eq!(nics[1].model, "e1000"); + assert_eq!(nics[1].macaddr, "11:22:33:44:55:66"); + assert_eq!(nics[1].bridge.as_deref(), Some("vmbr1")); + assert_eq!(nics[1].tag, None); + assert_eq!(nics[1].firewall, None); + assert_eq!(nics[1].link_down, None); + mock.assert(); +} + +#[tokio::test] +async fn add_nic_posts_form() { + let server = MockServer::start(); + let token = "root@pam!add-nic-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": {"memory": 2048} + }) + .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`. + .body_includes("net0=virtio%3Drandom%2Cbridge%3Dvmbr0"); + 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: "virtio".to_string(), + macaddr: None, + tag: None, + firewall: None, + }; + manager + .add_nic("conn", "pve1", 100, "qemu", config) + .await + .expect("nic should be added"); + + get_mock.assert(); + post_mock.assert(); +} + +#[tokio::test] +async fn edit_nic_preserves_model_and_mac() { + let server = MockServer::start(); + let token = "root@pam!edit-nic-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=MAC,bridge=vmbr0"} + }) + .to_string(), + ); + }); + let post_mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/config") + .body_includes("net0=virtio%3DMAC%2Cbridge%3Dvmbr1"); + 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: 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(); + let token = "root@pam!remove-nic-token"; + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/api2/json/nodes/pve1/qemu/100/config") + .body_includes("delete=net0"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .remove_nic("conn", "pve1", 100, "qemu", "net0") + .await + .expect("nic should be removed"); + + mock.assert(); +} + +#[tokio::test] +async fn lxc_vm_types_use_lxc_path() { + let server = MockServer::start(); + let token = "root@pam!lxc-token"; + let config_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": { + "rootfs": "local:vm-201-disk-0,size=8G", + "mp0": "local:vm-201-mp-0,size=4G", + "net0": "name=eth0,bridge=vmbr0,ip=dhcp" + } + }) + .to_string(), + ); + }); + let resize_mock = server.mock(|when, then| { + when.method(PUT) + .path("/api2/json/nodes/pve1/lxc/201/resize") + .body_includes("disk=rootfs") + .body_includes("size=16G"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + 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. + 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" + ); + + let nics = manager + .get_network_interfaces("conn", "pve1", 201, "lxc") + .await + .expect("nics should be fetched"); + assert_eq!(nics.len(), 1); + assert_eq!(nics[0].name, "net0"); + assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0")); + + manager + .resize_disk("conn", "pve1", 201, "lxc", "rootfs", 17179869184) + .await + .expect("resize should succeed"); + + config_mock.assert_calls(2); + resize_mock.assert(); +} + +#[tokio::test] +async fn invalid_vm_type_errors() { + let server = MockServer::start(); + let probe = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes/pve1/kvm/100/config"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{}}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await; + + let error = manager + .get_disks("conn", "pve1", 100, "kvm") + .await + .expect_err("invalid vm type must be rejected"); + assert!( + matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")), + "expected InvalidUrl mentioning 'kvm', got: {}", + error + ); + + let config = AddNICConfig { + bridge: "vmbr0".to_string(), + model: "virtio".to_string(), + macaddr: None, + tag: None, + firewall: None, + }; + let error = manager + .add_nic("conn", "pve1", 100, "kvm", config) + .await + .expect_err("invalid vm type must be rejected"); + assert!( + matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")), + "expected InvalidUrl mentioning 'kvm', got: {}", + error + ); + + assert_eq!( + probe.calls(), + 0, + "no HTTP request should be made for an invalid vm type" + ); +} diff --git a/src-tauri/tests/api_snapshots_backups.rs b/src-tauri/tests/api_snapshots_backups.rs new file mode 100644 index 0000000..cf98bb1 --- /dev/null +++ b/src-tauri/tests/api_snapshots_backups.rs @@ -0,0 +1,448 @@ +//! Integration tests for the real snapshot, migration, and backup API calls. +//! +//! Every request goes through `Connection::request`, so these tests cover URL +//! construction, form encoding, auth header injection, response envelope +//! unwrapping, and mapping into the public serde structs. Token-mode +//! connections resolve the token from the config, so the methods can be called +//! directly on an added connection without `connect()`. + +use httpmock::prelude::*; +use proxmox_desktop::{ + BackupJobConfig, ConnectionConfig, ConnectionManager, CreateSnapshotConfig, EndpointConfig, + Error, +}; + +/// Builds a `ConnectionManager` with a single token-mode connection whose +/// primary endpoint points at the mock server. `node` pins the storage node. +async fn setup_manager( + url: &str, + token: &str, + node: Option<&str>, +) -> (ConnectionManager, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn".to_string(), + name: "conn".to_string(), + primary: EndpointConfig { + url: url.to_string(), + node: node.map(str::to_string), + token: Some(token.to_string()), + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + (manager, dir) +} + +#[tokio::test] +async fn get_snapshots_maps_list() { + let server = MockServer::start(); + let token = "root@pam!snap-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes/pve1/qemu/100/snapshot") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"name": "snap1", "description": "before upgrade", + "snaptime": 1700000000, "vmstate": 1, "parent": "current"}, + {"name": "snap2", "snaptime": 1700000100, "vmstate": 0} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let snapshots = manager + .get_snapshots("conn", "pve1", 100, "qemu") + .await + .expect("snapshots should be fetched"); + + assert_eq!(snapshots.len(), 2); + assert_eq!(snapshots[0].name, "snap1"); + assert_eq!(snapshots[0].description, "before upgrade"); + assert_eq!(snapshots[0].snaptime, 1700000000); + assert_eq!(snapshots[0].vmstate, 1); + assert_eq!(snapshots[0].parent.as_deref(), Some("current")); + // Tolerance: the second snapshot omits optional fields. + assert_eq!(snapshots[1].name, "snap2"); + assert_eq!(snapshots[1].description, ""); + assert_eq!(snapshots[1].parent, None); + mock.assert(); +} + +#[tokio::test] +async fn create_snapshot_posts_form() { + let server = MockServer::start(); + let token = "root@pam!snap-create-token"; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/snapshot") + .body_includes("snapname=snap1") + .body_includes("vmstate=1"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .create_snapshot( + "conn", + "pve1", + 100, + "qemu", + CreateSnapshotConfig { + name: "snap1".to_string(), + description: Some("before upgrade".to_string()), + vmstate: Some(true), + }, + ) + .await + .expect("snapshot should be created"); + mock.assert(); +} + +#[tokio::test] +async fn delete_snapshot_deletes_path() { + let server = MockServer::start(); + let token = "root@pam!snap-delete-token"; + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .delete_snapshot("conn", "pve1", 100, "qemu", "snap1") + .await + .expect("snapshot should be deleted"); + mock.assert(); +} + +#[tokio::test] +async fn rollback_snapshot_posts_rollback() { + let server = MockServer::start(); + let token = "root@pam!snap-rollback-token"; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1/rollback"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .rollback_snapshot("conn", "pve1", 100, "qemu", "snap1") + .await + .expect("rollback should succeed"); + mock.assert(); +} + +#[tokio::test] +async fn migrate_vm_posts_migrate() { + let server = MockServer::start(); + let token = "root@pam!migrate-token"; + let online_mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/migrate") + .body_includes("target=pve2") + .body_includes("online=1"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + let offline_mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/migrate") + .body_includes("target=pve2") + .body_includes("online=0"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .migrate_vm("conn", "pve1", 100, "qemu", "pve2", true) + .await + .expect("online migration should succeed"); + manager + .migrate_vm("conn", "pve1", 100, "qemu", "pve2", false) + .await + .expect("offline migration should succeed"); + online_mock.assert(); + offline_mock.assert(); +} + +#[tokio::test] +async fn get_backup_jobs_maps_list() { + let server = MockServer::start(); + let token = "root@pam!jobs-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": [ + {"id": "backup-1", "store": "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", + "all": 0, "enabled": 0, "vmid": "100,101"} + ] + }) + .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(), 2); + assert_eq!(jobs[0].id, "backup-1"); + assert_eq!(jobs[0].store, "backup"); + assert_eq!(jobs[0].schedule, "0 2 * * *"); + assert_eq!(jobs[0].all, 1); + assert_eq!(jobs[0].enabled, 1); + assert_eq!(jobs[0].node.as_deref(), Some("pve1")); + 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. + assert_eq!(jobs[1].node, None); + assert_eq!(jobs[1].compress, None); + assert_eq!(jobs[1].mode, None); + assert_eq!(jobs[1].quiet, None); + assert_eq!(jobs[1].vmid.as_deref(), Some("100,101")); + assert_eq!(jobs[1].all, 0); + mock.assert(); +} + +#[tokio::test] +async fn create_backup_job_posts_form() { + let server = MockServer::start(); + let token = "root@pam!job-create-token"; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/cluster/backup") + .body_includes("schedule=0+2+*+*+*") + .body_includes("storage=backup") + .body_includes("mode=snapshot") + .body_includes("compress=zstd") + .body_includes("all=1") + .body_includes("enabled=1") + .body_includes("vmid=100%2C101") + .body_includes("node=pve1"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .create_backup_job( + "conn", + BackupJobConfig { + id: None, + storage: "backup".to_string(), + schedule: "0 2 * * *".to_string(), + mode: "snapshot".to_string(), + compression: "zstd".to_string(), + all: true, + vmid: Some("100,101".to_string()), + enabled: true, + node: Some("pve1".to_string()), + }, + ) + .await + .expect("backup job should be created"); + mock.assert(); +} + +#[tokio::test] +async fn delete_backup_job_deletes_path() { + let server = MockServer::start(); + let token = "root@pam!job-delete-token"; + let mock = server.mock(|when, then| { + when.method(DELETE).path("/api2/json/cluster/backup/job-id"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + manager + .delete_backup_job("conn", "job-id") + .await + .expect("backup job should be deleted"); + mock.assert(); +} + +#[tokio::test] +async fn get_backups_filters_and_maps() { + let server = MockServer::start(); + let token = "root@pam!backups-token"; + let mock = 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( + serde_json::json!({ + "data": [ + {"volid": "local: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": "local", + "size": 1073741824u64, "ctime": 1700000001, "content": "backup"}, + {"volid": "local:iso/debian-12.iso", "content": "iso", + "ctime": 1700000002}, + {"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl", + "ctime": 1700000003} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await; + let backups = manager + .get_backups("conn", Some("local")) + .await + .expect("backups should be fetched"); + + assert_eq!(backups.len(), 1); + assert_eq!( + backups[0].volid, + "local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst" + ); + assert_eq!( + backups[0].backupid, + "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst" + ); + assert_eq!(backups[0].backup_type, "qemu"); + assert_eq!(backups[0].backup_id, "100"); + assert_eq!(backups[0].backup_time, 1700000000); + assert_eq!(backups[0].storage, "local"); + assert_eq!(backups[0].size, 1073741824u64); + assert_eq!(backups[0].ctime, 1700000001); + mock.assert(); +} + +#[tokio::test] +async fn get_backups_defaults_to_local_storage() { + let server = MockServer::start(); + let token = "root@pam!backups-default-token"; + let mock = 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 fetched"); + + assert!(backups.is_empty()); + mock.assert(); +} + +#[tokio::test] +async fn delete_backup_url_encodes_volid() { + let server = MockServer::start(); + let token = "root@pam!backup-delete-token"; + let mock = server.mock(|when, then| { + when.method(DELETE).path( + "/api2/json/nodes/pve1/storage/local/content/local%3Abackup%2Fvzdump-qemu-100-2024_01_01-00_00_00.vma.zst", + ); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await; + manager + .delete_backup( + "conn", + "local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst", + ) + .await + .expect("backup should be deleted"); + mock.assert(); +} + +#[tokio::test] +async fn invalid_vm_type_errors() { + let server = MockServer::start(); + let probe = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes/pve1/kvm/100/snapshot"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[]}"#); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await; + + let error = manager + .get_snapshots("conn", "pve1", 100, "kvm") + .await + .expect_err("invalid vm type must be rejected"); + assert!( + matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")), + "expected InvalidUrl mentioning 'kvm', got: {}", + error + ); + + let error = manager + .migrate_vm("conn", "pve1", 100, "kvm", "pve2", true) + .await + .expect_err("invalid vm type must be rejected"); + assert!( + matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")), + "expected InvalidUrl mentioning 'kvm', got: {}", + error + ); + + assert_eq!( + probe.calls(), + 0, + "no HTTP request should be made for an invalid vm type" + ); +} diff --git a/src-tauri/tests/cluster_flow.rs b/src-tauri/tests/cluster_flow.rs new file mode 100644 index 0000000..d4544bb --- /dev/null +++ b/src-tauri/tests/cluster_flow.rs @@ -0,0 +1,467 @@ +//! Integration tests for the connect-time node discovery, same-cluster +//! merging, and the connection status reporting flow. +//! +//! `connect` now authenticates, resets failover state, and discovers the +//! cluster's nodes before either folding the connection into an existing +//! same-cluster connection or marking it connected. These tests cover the +//! discovered-node persistence, the merge of a second node's connection into +//! the first, the status reporting (including failover and disconnected), and +//! the all-endpoints-down case where the connect reports `"failed"` instead of +//! erroring. + +use httpmock::prelude::*; +use httpmock::Mock; +use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error}; + +const TOKEN: &str = "root@pam!cluster-token"; + +/// A minimal `/nodes` entry. The `Node` struct requires every field, so all +/// are included. +fn node_json(name: &str, status: &str) -> serde_json::Value { + serde_json::json!({ + "node": name, + "status": status, + "cpu": 0.0, + "maxcpu": 8, + "mem": 0, + "maxmem": 34359738368u64, + "disk": 0, + "maxdisk": 536870912000u64, + "uptime": 0, + "level": "", + "id": format!("node/{}", name), + "type": "node" + }) +} + +/// Builds the `/cluster/status` response body: one `cluster` entry followed by +/// the given node entries. +fn cluster_status_body( + cluster_id: &str, + cluster_name: &str, + nodes: &[serde_json::Value], +) -> String { + let mut entries = Vec::with_capacity(nodes.len() + 1); + entries.push(serde_json::json!({ + "type": "cluster", + "id": cluster_id, + "name": cluster_name, + "nodes": nodes.len() + })); + entries.extend_from_slice(nodes); + serde_json::json!({ "data": entries }).to_string() +} + +fn stub_version<'a>(server: &'a MockServer) -> Mock<'a> { + server.mock(|when, then| { + when.method(GET).path("/api2/json/version"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"version":"8.2"}}"#); + }) +} + +fn stub_nodes<'a>(server: &'a MockServer, nodes: &[serde_json::Value]) -> Mock<'a> { + server.mock(|when, then| { + when.method(GET).path("/api2/json/nodes"); + then.status(200) + .header("content-type", "application/json") + .body(serde_json::json!({ "data": nodes }).to_string()); + }) +} + +fn stub_cluster_status<'a>( + server: &'a MockServer, + cluster_id: &str, + cluster_name: &str, + nodes: &[serde_json::Value], +) -> Mock<'a> { + server.mock(|when, then| { + when.method(GET).path("/api2/json/cluster/status"); + then.status(200) + .header("content-type", "application/json") + .body(cluster_status_body(cluster_id, cluster_name, nodes)); + }) +} + +/// Returns a URL pointing at a closed TCP port: a listener is bound to an +/// ephemeral port, its address is captured, and the listener is dropped so any +/// subsequent connection attempt is refused. +fn closed_port_url() -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let addr = listener.local_addr().expect("address should be available"); + drop(listener); + format!("http://{}", addr) +} + +fn fallback_config(url: &str) -> EndpointConfig { + EndpointConfig { + url: url.to_string(), + node: None, + token: Some(TOKEN.to_string()), + } +} + +/// Builds a token-mode connection config that skips certificate verification +/// (the mock servers speak plain HTTP). +fn token_config(id: &str, url: &str, fallbacks: Vec) -> ConnectionConfig { + ConnectionConfig { + id: id.to_string(), + name: id.to_string(), + primary: EndpointConfig { + url: url.to_string(), + node: None, + token: Some(TOKEN.to_string()), + }, + fallbacks, + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + } +} + +async fn add_connection( + manager: &mut ConnectionManager, + path: &std::path::Path, + config: ConnectionConfig, +) { + manager + .add_connection(config, path) + .await + .expect("connection should be added"); +} + +#[tokio::test] +async fn connect_discovers_and_persists_nodes() { + let server = MockServer::start(); + let port = server.port(); + + stub_version(&server); + stub_nodes( + &server, + &[node_json("pve1", "online"), node_json("pve2", "online")], + ); + stub_cluster_status( + &server, + "cluster/lab", + "lab", + &[ + serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1, + "online": 1, "local": 1, "ip": "10.0.0.5"}), + serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2, + "online": 1, "local": 0, "ip": "10.0.0.6"}), + ], + ); + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + add_connection( + &mut manager, + &path, + token_config("conn", &server.base_url(), vec![]), + ) + .await; + + let result = manager + .connect("conn", &path) + .await + .expect("connect should succeed"); + assert_eq!(result.status, "connected"); + assert_eq!(result.merged_into, None); + assert_eq!(result.connection_id, "conn"); + + let config = manager + .connection_config("conn") + .expect("config should be readable"); + assert_eq!(config.nodes.len(), 2); + assert_eq!(config.cluster_id.as_deref(), Some("cluster/lab")); + assert_eq!(config.primary.node.as_deref(), Some("pve1")); + + // The discovered nodes and cluster identity are persisted. + let raw = std::fs::read_to_string(&path).expect("file should be written"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON"); + let conn_json = &json["connections"][0]; + assert_eq!(conn_json["clusterId"], "cluster/lab"); + assert_eq!( + conn_json["nodes"] + .as_array() + .expect("nodes should be present") + .len(), + 2 + ); + assert_eq!( + conn_json["nodes"][0]["url"], + format!("http://10.0.0.5:{}", port), + "the primary node's URL must be persisted" + ); + assert_eq!(conn_json["status"], "connected"); +} + +#[tokio::test] +async fn connect_second_node_merges_into_existing() { + let server_a = MockServer::start(); + let server_b = MockServer::start(); + + // Both servers report the same cluster identity, as two nodes of one + // cluster would. + for (server, ip1, ip2) in [ + (&server_a, "10.0.0.5", "10.0.0.6"), + (&server_b, "10.0.0.7", "10.0.0.8"), + ] { + stub_version(server); + stub_nodes( + server, + &[node_json("pve1", "online"), node_json("pve2", "online")], + ); + stub_cluster_status( + server, + "cluster/lab", + "lab", + &[ + serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1, + "online": 1, "local": 1, "ip": ip1}), + serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2, + "online": 1, "local": 0, "ip": ip2}), + ], + ); + } + let vms_mock = server_a.mock(|when, then| { + when.method(GET) + .path("/api2/json/cluster/resources") + .query_param("type", "vm"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [{"vmid": 100, "name": "web01", "type": "qemu", + "status": "running", "node": "pve1"}] + }) + .to_string(), + ); + }); + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + add_connection( + &mut manager, + &path, + token_config("conn-a", &server_a.base_url(), vec![]), + ) + .await; + add_connection( + &mut manager, + &path, + token_config("conn-b", &server_b.base_url(), vec![]), + ) + .await; + + let first = manager + .connect("conn-a", &path) + .await + .expect("first connect should succeed"); + assert_eq!(first.status, "connected"); + assert_eq!(first.merged_into, None); + + let second = manager + .connect("conn-b", &path) + .await + .expect("second connect should succeed"); + assert_eq!(second.status, "connected"); + assert_eq!(second.connection_id, "conn-a"); + assert_eq!(second.merged_into.as_deref(), Some("conn-a")); + + // The merged connection is gone and conn-a carries B's endpoint. + assert!( + matches!( + manager.connection_config("conn-b"), + Err(Error::ConnectionNotFound(ref id)) if id == "conn-b" + ), + "conn-b must be removed after merging" + ); + let config_a = manager + .connection_config("conn-a") + .expect("config should be readable"); + assert!( + config_a + .fallbacks + .iter() + .any(|endpoint| endpoint.url == server_b.base_url()), + "conn-a must have B's URL as a fallback" + ); + assert_eq!( + config_a.nodes.len(), + 4, + "the node lists of both servers must be merged" + ); + + // Only the surviving connection is persisted. + let raw = std::fs::read_to_string(&path).expect("file should be written"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON"); + let ids: Vec<&str> = json["connections"] + .as_array() + .expect("connections should be an array") + .iter() + .filter_map(|c| c["id"].as_str()) + .collect(); + assert_eq!(ids, vec!["conn-a"]); + + assert_eq!( + manager + .runtime_status("conn-a") + .expect("status should be readable"), + "connected" + ); + + // A subsequent data request on the merged connection is served by A. + let vms = manager + .get_vms("conn-a") + .await + .expect("get_vms should succeed via A"); + assert_eq!(vms.len(), 1); + vms_mock.assert(); +} + +#[tokio::test] +async fn status_info_reports_failover() { + let fallback = MockServer::start(); + stub_version(&fallback); + stub_nodes(&fallback, &[node_json("pve1", "online")]); + stub_cluster_status( + &fallback, + "cluster/lab", + "lab", + &[ + serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1, + "online": 1, "local": 1, "ip": "10.0.0.5"}), + ], + ); + let vms_mock = fallback.mock(|when, then| { + when.method(GET) + .path("/api2/json/cluster/resources") + .query_param("type", "vm"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [{"vmid": 100, "name": "web01", "type": "qemu", + "status": "running", "node": "pve1"}] + }) + .to_string(), + ); + }); + + let primary_url = closed_port_url(); + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + add_connection( + &mut manager, + &path, + token_config( + "conn", + &primary_url, + vec![fallback_config(&fallback.base_url())], + ), + ) + .await; + + let result = manager + .connect("conn", &path) + .await + .expect("connect should succeed via the fallback"); + assert_eq!(result.status, "connected"); + + let vms = manager + .get_vms("conn") + .await + .expect("get_vms should succeed via the fallback"); + assert_eq!(vms.len(), 1); + assert_eq!( + manager + .runtime_status("conn") + .expect("status should be readable"), + "failover" + ); + + let info = manager + .status_info("conn") + .await + .expect("status info should be readable"); + assert_eq!(info.status, "failover"); + assert_eq!(info.current_endpoint_url, fallback.base_url()); + assert_eq!(info.primary_url, primary_url); + vms_mock.assert(); +} + +#[tokio::test] +async fn status_info_when_disconnected() { + let server = MockServer::start(); + let version_mock = stub_version(&server); + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + add_connection( + &mut manager, + &path, + token_config("conn", &server.base_url(), vec![]), + ) + .await; + + let info = manager + .status_info("conn") + .await + .expect("status info should be readable"); + assert_eq!(info.status, "disconnected"); + assert_eq!(info.primary_url, server.base_url()); + assert_eq!(info.current_endpoint_url, server.base_url()); + assert!(info.nodes.is_empty()); + assert_eq!( + version_mock.hits(), + 0, + "a disconnected connection must not hit the network" + ); +} + +#[tokio::test] +async fn connect_all_endpoints_down_returns_failed_result() { + let primary_url = closed_port_url(); + let fallback_url = closed_port_url(); + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + add_connection( + &mut manager, + &path, + token_config("conn", &primary_url, vec![fallback_config(&fallback_url)]), + ) + .await; + + let result = manager + .connect("conn", &path) + .await + .expect("an unreachable cluster must be reported, not errored"); + assert_eq!(result.status, "failed"); + assert_eq!(result.merged_into, None); + assert_eq!(result.connection_id, "conn"); + + let config = manager + .connection_config("conn") + .expect("config should be readable"); + assert_eq!(config.status, "failed"); + + // The failed status is persisted so the connection stays tracked. + let raw = std::fs::read_to_string(&path).expect("file should be written"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON"); + assert_eq!(json["connections"][0]["status"], "failed"); +} diff --git a/src-tauri/tests/discovery.rs b/src-tauri/tests/discovery.rs new file mode 100644 index 0000000..3e2718d --- /dev/null +++ b/src-tauri/tests/discovery.rs @@ -0,0 +1,289 @@ +//! Integration tests for cluster node auto-discovery. +//! +//! `discover_nodes` combines `/nodes` (the node list and statuses) with +//! `/cluster/status` (cluster IPs, local flags, and cluster identity) and +//! derives an endpoint URL per node from the connection's primary URL. These +//! tests cover the URL derivation rules, primary-node marking, the config +//! mutation, and the persist -> load round-trip. + +use httpmock::prelude::*; +use httpmock::Mock; +use proxmox_desktop::{derive_node_url, ConnectionConfig, ConnectionManager, EndpointConfig}; + +const TOKEN: &str = "root@pam!discovery-token"; + +/// A minimal `/nodes` entry. The `Node` struct requires every field, so all +/// are included. +fn node_json(name: &str, status: &str) -> serde_json::Value { + serde_json::json!({ + "node": name, + "status": status, + "cpu": 0.0, + "maxcpu": 8, + "mem": 0, + "maxmem": 34359738368u64, + "disk": 0, + "maxdisk": 536870912000u64, + "uptime": 0, + "level": "", + "id": format!("node/{}", name), + "type": "node" + }) +} + +/// Builds a `ConnectionManager` with a single token-mode connection whose +/// primary endpoint is the mock server. `node` pins `primary.node`. +async fn setup_manager( + server: &MockServer, + node: Option<&str>, +) -> (ConnectionManager, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn".to_string(), + name: "conn".to_string(), + primary: EndpointConfig { + url: server.base_url(), + node: node.map(str::to_string), + token: Some(TOKEN.to_string()), + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + (manager, dir) +} + +/// Stubs `GET /api2/json/nodes` returning `nodes` (entries from `node_json`). +fn stub_nodes<'a>(server: &'a MockServer, nodes: &[serde_json::Value]) -> Mock<'a> { + server.mock(|when, then| { + when.method(GET).path("/api2/json/nodes"); + then.status(200) + .header("content-type", "application/json") + .body(serde_json::json!({ "data": nodes }).to_string()); + }) +} + +/// Builds the `/cluster/status` response body: one `cluster` entry followed by +/// the given node entries. +fn cluster_status_body( + cluster_id: &str, + cluster_name: &str, + nodes: &[serde_json::Value], +) -> String { + let mut entries = Vec::with_capacity(nodes.len() + 1); + entries.push(serde_json::json!({ + "type": "cluster", + "id": cluster_id, + "name": cluster_name, + "nodes": nodes.len() + })); + entries.extend_from_slice(nodes); + serde_json::json!({ "data": entries }).to_string() +} + +/// Stubs `GET /api2/json/cluster/status` returning the cluster entry plus the +/// given node entries. +fn stub_cluster_status<'a>( + server: &'a MockServer, + cluster_id: &str, + cluster_name: &str, + nodes: &[serde_json::Value], +) -> Mock<'a> { + server.mock(|when, then| { + when.method(GET).path("/api2/json/cluster/status"); + then.status(200) + .header("content-type", "application/json") + .body(cluster_status_body(cluster_id, cluster_name, nodes)); + }) +} + +#[test] +fn derive_node_url_keeps_scheme_and_port() { + assert_eq!( + derive_node_url("https://10.0.0.5:8006", Some("10.0.0.6"), "pve1"), + "https://10.0.0.6:8006" + ); + // An empty IP falls back to the node name. + assert_eq!( + derive_node_url("http://pve.lan:8006", Some(""), "pve.lan"), + "http://pve.lan:8006" + ); + // A missing port defaults to pveproxy's 8006 for both schemes. + assert_eq!( + derive_node_url("https://pve.lan", Some("10.0.0.7"), "pve1"), + "https://10.0.0.7:8006" + ); + // An unparseable primary URL falls back to a best-effort https URL. + assert_eq!( + derive_node_url("not a url", Some("10.0.0.8"), "pve1"), + "https://10.0.0.8:8006" + ); +} + +#[tokio::test] +async fn discover_nodes_builds_and_marks_primary() { + let server = MockServer::start(); + let port = server.port(); + + let nodes_mock = stub_nodes( + &server, + &[node_json("pve1", "online"), node_json("pve2", "online")], + ); + let cluster_mock = stub_cluster_status( + &server, + "cluster/lab", + "lab", + &[ + serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1, + "online": 1, "local": 1, "ip": "10.0.0.5"}), + serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2, + "online": 1, "local": 0, "ip": "10.0.0.6"}), + ], + ); + + let (mut manager, _dir) = setup_manager(&server, Some("pve1")).await; + let discovered = manager + .discover_nodes("conn") + .await + .expect("discovery should succeed"); + + assert_eq!(discovered.len(), 2); + assert_eq!( + discovered[0].name, "pve1", + "the primary node must sort first" + ); + assert!(discovered[0].is_primary); + assert!(discovered[0].local); + assert_eq!(discovered[0].status, "online"); + assert_eq!( + discovered[0].url, + format!("http://10.0.0.5:{}", port), + "pve1's URL keeps the scheme and port but uses the cluster IP" + ); + assert_eq!(discovered[1].name, "pve2"); + assert!(!discovered[1].is_primary); + assert!(!discovered[1].local); + assert_eq!(discovered[1].url, format!("http://10.0.0.6:{}", port)); + + let config = manager + .connection_config("conn") + .expect("config should be readable"); + assert_eq!(config.cluster_name.as_deref(), Some("lab")); + assert_eq!(config.cluster_id.as_deref(), Some("cluster/lab")); + assert_eq!(config.primary.node.as_deref(), Some("pve1")); + assert_eq!( + config.nodes, discovered, + "the discovered list must be stored" + ); + + nodes_mock.assert(); + cluster_mock.assert(); +} + +#[tokio::test] +async fn discover_nodes_persists_and_round_trips() { + let server = MockServer::start(); + + let nodes_mock = stub_nodes( + &server, + &[node_json("pve1", "online"), node_json("pve2", "offline")], + ); + let cluster_mock = stub_cluster_status( + &server, + "cluster/lab", + "lab", + &[ + serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1, + "online": 1, "local": 1, "ip": "10.0.0.5"}), + serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2, + "online": 0, "local": 0, "ip": "10.0.0.6"}), + ], + ); + + let (mut manager, dir) = setup_manager(&server, Some("pve1")).await; + let path = dir.path().join("connections.json"); + let discovered = manager + .discover_nodes("conn") + .await + .expect("discovery should succeed"); + + // discover_nodes stores on the config but does not persist by itself; any + // later persist (here via set_active_connection) writes the new fields. + manager + .set_active_connection("conn".to_string(), &path) + .await + .expect("active connection should be set"); + + let mut reloaded = ConnectionManager::new(); + let result = reloaded + .load_connections(&path) + .await + .expect("connections should load"); + assert_eq!(result.connections.len(), 1); + let loaded = &result.connections[0]; + assert_eq!(loaded.nodes, discovered, "discovered nodes must round-trip"); + assert_eq!(loaded.cluster_id.as_deref(), Some("cluster/lab")); + assert_eq!(loaded.cluster_name.as_deref(), Some("lab")); + + nodes_mock.assert(); + cluster_mock.assert(); +} + +#[tokio::test] +async fn discover_nodes_sets_primary_node_from_url_match() { + let server = MockServer::start(); + // The mock server's host is used as the node's cluster IP so the derived + // URL equals the primary URL. + let host = server.host(); + + let nodes_mock = stub_nodes(&server, &[node_json("pve1", "online")]); + let cluster_mock = stub_cluster_status( + &server, + "cluster/lab", + "lab", + &[ + serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1, + "online": 1, "local": 1, "ip": host}), + ], + ); + + let (mut manager, _dir) = setup_manager(&server, None).await; + let discovered = manager + .discover_nodes("conn") + .await + .expect("discovery should succeed"); + + assert_eq!(discovered.len(), 1); + assert_eq!( + discovered[0].url, + server.base_url(), + "the derived URL must equal the primary URL" + ); + assert!( + discovered[0].is_primary, + "a node whose URL matches the primary URL must be marked primary" + ); + + // With primary.node unset, discovery pins it to the primary node. + let config = manager + .connection_config("conn") + .expect("config should be readable"); + assert_eq!(config.primary.node.as_deref(), Some("pve1")); + + nodes_mock.assert(); + cluster_mock.assert(); +} diff --git a/src-tauri/tests/failover.rs b/src-tauri/tests/failover.rs new file mode 100644 index 0000000..34a741d --- /dev/null +++ b/src-tauri/tests/failover.rs @@ -0,0 +1,202 @@ +//! Integration tests for automatic failover across cluster endpoints. +//! +//! Transport-level failures (connection refused, timeouts, DNS resolution) +//! must be distinguishable from real authentication failures so the request +//! core can rotate to the next configured endpoint. These tests cover the +//! error mapping in `api_request` and the rotation logic in +//! `Connection::request`. + +use httpmock::prelude::*; +use httpmock::Mock; +use proxmox_desktop::{ + api_request, AuthContext, AuthMode, ConnectionConfig, ConnectionManager, EndpointConfig, Error, +}; +use reqwest::Client; +use reqwest::Method as RMethod; + +const TOKEN: &str = "root@pam!failover-token"; + +/// Returns a URL pointing at a closed TCP port: a listener is bound to an +/// ephemeral port, its address is captured, and the listener is dropped so +/// any subsequent connection attempt is refused. +fn closed_port_url() -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let addr = listener.local_addr().expect("address should be available"); + drop(listener); + format!("http://{}", addr) +} + +fn token_auth() -> AuthContext { + AuthContext { + mode: AuthMode::Token, + token: Some(TOKEN.to_string()), + ticket: None, + csrf_token: None, + } +} + +/// Builds a `ConnectionManager` with a single token-mode connection whose +/// primary endpoint is `primary_url` and whose fallbacks are `fallback_urls`. +async fn setup_manager_with_fallbacks( + primary_url: &str, + fallback_urls: &[String], +) -> (ConnectionManager, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn".to_string(), + name: "conn".to_string(), + primary: EndpointConfig { + url: primary_url.to_string(), + node: None, + token: Some(TOKEN.to_string()), + }, + fallbacks: fallback_urls + .iter() + .map(|url| EndpointConfig { + url: url.clone(), + node: None, + token: Some(TOKEN.to_string()), + }) + .collect(), + cert_fingerprint: None, + trusted: false, + accept_untrusted: true, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + (manager, dir) +} + +/// Stubs `GET /api2/json/cluster/resources?type=vm` on `server`. +fn stub_vm_resources<'a>(server: &'a MockServer, status: u16, body: &str) -> Mock<'a> { + server.mock(|when, then| { + when.method(GET) + .path("/api2/json/cluster/resources") + .query_param("type", "vm"); + then.status(status) + .header("content-type", "application/json") + .body(body); + }) +} + +#[tokio::test] +async fn transport_error_maps_to_connection_failed() { + let url = closed_port_url(); + let error = api_request( + &Client::new(), + &url, + RMethod::GET, + "/version", + &token_auth(), + &[], + None, + ) + .await + .expect_err("connection refused must fail"); + + assert!( + matches!(error, Error::ConnectionFailed(_)), + "expected ConnectionFailed, got: {}", + error + ); +} + +#[tokio::test] +async fn request_rotates_to_fallback_when_primary_down() { + let fallback = MockServer::start(); + let fallback_mock = stub_vm_resources( + &fallback, + 200, + &serde_json::json!({ + "data": [ + {"vmid": 100, "name": "web01", "type": "qemu", "status": "running", + "node": "pve1"} + ] + }) + .to_string(), + ); + + let primary_url = closed_port_url(); + let (manager, _dir) = setup_manager_with_fallbacks(&primary_url, &[fallback.base_url()]).await; + + let vms = manager + .get_vms("conn") + .await + .expect("request should succeed via the fallback endpoint"); + + assert_eq!(vms.len(), 1); + assert_eq!(vms[0].vmid, 100); + assert_eq!(vms[0].name.as_deref(), Some("web01")); + assert_eq!( + manager + .runtime_status("conn") + .expect("status should be readable"), + "failover" + ); + fallback_mock.assert(); +} + +#[tokio::test] +async fn non_transport_error_does_not_rotate() { + let primary = MockServer::start(); + let fallback = MockServer::start(); + let primary_mock = stub_vm_resources(&primary, 500, r#"{"message":"boom"}"#); + let fallback_mock = stub_vm_resources(&fallback, 200, r#"{"data":[]}"#); + + let (manager, _dir) = + setup_manager_with_fallbacks(&primary.base_url(), &[fallback.base_url()]).await; + + let error = manager + .get_vms("conn") + .await + .expect_err("a 500 response must surface as an ApiError"); + + assert!( + matches!(error, Error::ApiError(ref message) if message == "boom"), + "expected ApiError with message 'boom', got: {}", + error + ); + primary_mock.assert(); + assert_eq!( + fallback_mock.hits(), + 0, + "a non-transport error must not trigger failover" + ); +} + +#[tokio::test] +async fn request_succeeds_on_primary_again_when_it_returns() { + let primary = MockServer::start(); + let fallback = MockServer::start(); + let primary_mock = stub_vm_resources(&primary, 200, r#"{"data":[]}"#); + let fallback_mock = stub_vm_resources(&fallback, 200, r#"{"data":[]}"#); + + let (manager, _dir) = + setup_manager_with_fallbacks(&primary.base_url(), &[fallback.base_url()]).await; + + let vms = manager + .get_vms("conn") + .await + .expect("request should succeed on the primary endpoint"); + + assert!(vms.is_empty()); + primary_mock.assert(); + assert_eq!(fallback_mock.hits(), 0); + assert_eq!( + manager + .runtime_status("conn") + .expect("status should be readable"), + "connected" + ); +} diff --git a/src-tauri/tests/persistence.rs b/src-tauri/tests/persistence.rs new file mode 100644 index 0000000..bd57afb --- /dev/null +++ b/src-tauri/tests/persistence.rs @@ -0,0 +1,489 @@ +//! Integration tests for connection persistence and the connect/disconnect +//! flow. +//! +//! Connection configs are persisted to a JSON file with secrets (API tokens) +//! stripped, so they never round-trip through the file; after a restart the +//! request layer falls back to the OS keyring. + +use httpmock::prelude::*; +use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error}; + +/// Builds a token-mode connection config with a real token set. +fn token_config(id: &str, url: &str, token: &str, accept_untrusted: bool) -> ConnectionConfig { + ConnectionConfig { + id: id.to_string(), + name: id.to_string(), + primary: EndpointConfig { + url: url.to_string(), + node: None, + token: Some(token.to_string()), + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + } +} + +#[tokio::test] +async fn persisted_configs_round_trip_without_secrets() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + + let mut manager = ConnectionManager::new(); + let config = ConnectionConfig { + id: "conn-1".to_string(), + name: "Proxmox".to_string(), + primary: EndpointConfig { + url: "https://pve.local:8006".to_string(), + node: None, + token: Some("root@pam!primary-secret".to_string()), + }, + fallbacks: vec![EndpointConfig { + url: "https://backup.local:8006".to_string(), + node: None, + token: Some("root@pam!backup-secret".to_string()), + }], + cert_fingerprint: None, + trusted: false, + accept_untrusted: false, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + }; + manager + .add_connection(config, &path) + .await + .expect("connection should be added"); + + // The persisted file must contain the connection but never its secrets. + let raw = std::fs::read_to_string(&path).expect("file should be written"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON"); + assert_eq!(json["activeConnectionId"], serde_json::Value::Null); + let conn = &json["connections"][0]; + assert_eq!(conn["id"], "conn-1"); + assert_eq!(conn["name"], "Proxmox"); + assert!( + conn["primary"].get("token").is_none(), + "primary token must not be serialized" + ); + assert!( + conn["fallbacks"][0].get("token").is_none(), + "fallback token must not be serialized" + ); + assert!( + conn.get("certFingerprint").is_none(), + "absent certFingerprint must not be serialized" + ); + + // Reloading from disk rebuilds the connection; the token does not + // round-trip and every connection starts disconnected. + let mut reloaded = ConnectionManager::new(); + let result = reloaded + .load_connections(&path) + .await + .expect("connections should load"); + assert_eq!(result.connections.len(), 1); + let loaded = &result.connections[0]; + assert_eq!(loaded.id, "conn-1"); + assert_eq!(loaded.name, "Proxmox"); + assert!( + loaded.primary.token.is_none(), + "token must not round-trip through the file" + ); + assert_eq!(loaded.status, "disconnected"); +} + +#[tokio::test] +async fn load_connections_with_missing_file_returns_empty() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("missing").join("connections.json"); + + let mut manager = ConnectionManager::new(); + let result = manager + .load_connections(&path) + .await + .expect("missing file is not an error"); + assert!(result.active_connection_id.is_none()); + assert!(result.connections.is_empty()); +} + +#[tokio::test] +async fn remove_connection_persists_and_keeps_active() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + + let mut manager = ConnectionManager::new(); + manager + .add_connection( + token_config("conn-1", "https://one.local:8006", "tok-1", false), + &path, + ) + .await + .expect("connection 1 should be added"); + manager + .add_connection( + token_config("conn-2", "https://two.local:8006", "tok-2", false), + &path, + ) + .await + .expect("connection 2 should be added"); + manager + .set_active_connection("conn-2".to_string(), &path) + .await + .expect("active connection should be set"); + + manager + .remove_connection("conn-1", &path) + .await + .expect("connection 1 should be removed"); + + let raw = std::fs::read_to_string(&path).expect("file should be written"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON"); + let ids: Vec<&str> = json["connections"] + .as_array() + .expect("connections should be an array") + .iter() + .filter_map(|c| c["id"].as_str()) + .collect(); + assert_eq!( + ids, + vec!["conn-2"], + "removed connection must not be persisted" + ); + assert_eq!( + json["activeConnectionId"], "conn-2", + "removing a non-active connection must keep the active id" + ); + + let mut reloaded = ConnectionManager::new(); + let result = reloaded + .load_connections(&path) + .await + .expect("connections should load"); + assert_eq!(result.connections.len(), 1); + assert_eq!(result.connections[0].id, "conn-2"); + assert_eq!(result.active_connection_id.as_deref(), Some("conn-2")); +} + +#[tokio::test] +async fn update_connection_preserves_cert_settings_and_replaces_config() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + + let mut manager = ConnectionManager::new(); + manager + .add_connection( + token_config("conn-1", "https://one.local:8006", "tok-1", true), + &path, + ) + .await + .expect("connection should be added"); + manager + .trust_certificate("conn-1", "AB:CD:EF", &path) + .await + .expect("certificate should be pinned"); + + // Update the name while omitting the certificate fields; the pinned + // fingerprint and trust settings must survive the update. + let updated = ConnectionConfig { + id: "conn-1".to_string(), + name: "Renamed".to_string(), + primary: EndpointConfig { + url: "https://one.local:8006".to_string(), + node: None, + token: None, + }, + fallbacks: vec![], + cert_fingerprint: None, + trusted: false, + accept_untrusted: false, + status: "disconnected".to_string(), + cluster_name: None, + is_cluster: false, + auth_mode: "token".to_string(), + username: None, + nodes: vec![], + cluster_id: None, + }; + manager + .update_connection(updated, &path) + .await + .expect("connection should be updated"); + + let raw = std::fs::read_to_string(&path).expect("file should be written"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON"); + let conn = &json["connections"][0]; + assert_eq!(conn["id"], "conn-1"); + assert_eq!(conn["name"], "Renamed"); + assert_eq!( + conn["certFingerprint"], "AB:CD:EF", + "cert_fingerprint must be preserved when omitted" + ); + assert_eq!( + conn["trusted"], true, + "trusted must be preserved when omitted" + ); + assert_eq!( + conn["acceptUntrusted"], true, + "accept_untrusted must be preserved when omitted" + ); + + // The updated config is what the reloaded manager serves. + let mut reloaded = ConnectionManager::new(); + let result = reloaded + .load_connections(&path) + .await + .expect("connections should load"); + assert_eq!(result.connections.len(), 1); + assert_eq!(result.connections[0].name, "Renamed"); +} + +#[tokio::test] +async fn update_connection_unknown_id_fails() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + + let mut manager = ConnectionManager::new(); + let error = manager + .update_connection( + token_config("missing", "https://nope.local:8006", "tok", false), + &path, + ) + .await + .expect_err("updating an unknown connection must fail"); + assert!( + matches!(error, Error::ConnectionNotFound(ref id) if id == "missing"), + "expected ConnectionNotFound for the missing id, got: {}", + error + ); +} + +#[tokio::test] +async fn remove_active_connection_clears_active_id() { + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + + let mut manager = ConnectionManager::new(); + manager + .add_connection( + token_config("conn-1", "https://one.local:8006", "tok-1", false), + &path, + ) + .await + .expect("connection should be added"); + manager + .set_active_connection("conn-1".to_string(), &path) + .await + .expect("active connection should be set"); + + manager + .remove_connection("conn-1", &path) + .await + .expect("connection should be removed"); + + let raw = std::fs::read_to_string(&path).expect("file should be written"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON"); + assert!( + json["activeConnectionId"].is_null(), + "removing the active connection must clear the active id" + ); +} + +#[tokio::test] +async fn connect_token_mode_validates_version_endpoint() { + let server = MockServer::start(); + let token = "root@pam!test-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/version") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"version":"8.2","release":"8.2.4","repoid":"abc"}}"#); + }); + // Connect also discovers the cluster's nodes, so the discovery endpoints + // are stubbed (empty cluster: no node entries, no cluster identity). + server.mock(|when, then| { + when.method(GET).path("/api2/json/nodes"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[]}"#); + }); + server.mock(|when, then| { + when.method(GET).path("/api2/json/cluster/status"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[]}"#); + }); + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + + let mut manager = ConnectionManager::new(); + // `accept_untrusted` skips the TLS capture, which is required here because + // the mock server speaks plain HTTP. + manager + .add_connection( + token_config("conn-token", &server.base_url(), token, true), + &path, + ) + .await + .expect("connection should be added"); + + let result = manager + .connect("conn-token", &path) + .await + .expect("connect should succeed against the mock server"); + assert_eq!(result.status, "connected"); + assert_eq!(result.merged_into, None); + mock.assert(); + + // The status update is persisted alongside the connection. + let raw = std::fs::read_to_string(&path).expect("file should be written"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON"); + assert_eq!( + json["connections"][0]["status"], "connected", + "connect must persist the connected status" + ); +} + +#[tokio::test] +async fn connect_without_cert_pin_and_no_escape_hatch_fails() { + let server = MockServer::start(); + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + + let mut manager = ConnectionManager::new(); + manager + .add_connection( + token_config("conn-untrusted", &server.base_url(), "tok", false), + &path, + ) + .await + .expect("connection should be added"); + + // The guard rejects the connect before any TLS is attempted (the guard is + // hit regardless of the scheme, so the http:// mock URL is fine here). + let error = manager + .connect("conn-untrusted", &path) + .await + .expect_err("connect must fail without a pin or escape hatch"); + assert!( + matches!(error, Error::CertificateError(ref message) if message.contains("not been trusted")), + "expected CertificateError about the untrusted certificate, got: {}", + error + ); +} + +#[tokio::test] +async fn connect_accept_untrusted_escape_hatch_reaches_version() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api2/json/version"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"version":"8.2"}}"#); + }); + server.mock(|when, then| { + when.method(GET).path("/api2/json/nodes"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[]}"#); + }); + server.mock(|when, then| { + when.method(GET).path("/api2/json/cluster/status"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[]}"#); + }); + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + + let mut manager = ConnectionManager::new(); + manager + .add_connection( + token_config("conn-escape", &server.base_url(), "tok", true), + &path, + ) + .await + .expect("connection should be added"); + + let result = manager + .connect("conn-escape", &path) + .await + .expect("the escape hatch must skip certificate verification"); + assert_eq!(result.status, "connected"); + mock.assert(); + + let raw = std::fs::read_to_string(&path).expect("file should be written"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON"); + assert_eq!(json["connections"][0]["status"], "connected"); +} + +#[tokio::test] +async fn disconnect_clears_session_and_status() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api2/json/version"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"version":"8.2"}}"#); + }); + server.mock(|when, then| { + when.method(GET).path("/api2/json/nodes"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[]}"#); + }); + server.mock(|when, then| { + when.method(GET).path("/api2/json/cluster/status"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[]}"#); + }); + + let dir = tempfile::tempdir().expect("temp dir should be created"); + let path = dir.path().join("connections.json"); + + let mut manager = ConnectionManager::new(); + manager + .add_connection( + token_config("conn-disc", &server.base_url(), "tok", true), + &path, + ) + .await + .expect("connection should be added"); + let result = manager + .connect("conn-disc", &path) + .await + .expect("connect should succeed"); + assert_eq!(result.status, "connected"); + + manager + .disconnect("conn-disc") + .await + .expect("disconnect should succeed"); + + // Reconnecting after a disconnect works, so the credentials used to + // authenticate (the in-config token) must still be available. This + // triggers a second request to the version endpoint. + let result = manager + .connect("conn-disc", &path) + .await + .expect("reconnect after disconnect should succeed"); + assert_eq!(result.status, "connected"); + mock.assert_hits(2); +} diff --git a/src-tauri/tests/tls.rs b/src-tauri/tests/tls.rs new file mode 100644 index 0000000..a287ac7 --- /dev/null +++ b/src-tauri/tests/tls.rs @@ -0,0 +1,186 @@ +//! Integration tests for the application-layer TLS certificate capture and +//! TOFU (Trust On First Use) pinning helpers. +//! +//! Each test spawns a local TLS server with a freshly generated self-signed +//! certificate and exercises the capture/verify helpers against it. + +use proxmox_desktop::tls::{ + capture_fingerprint, fetch_certificate_info, verify_pin, verify_server_certificate, +}; +use proxmox_desktop::Error; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use rustls::ServerConfig; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use tokio_rustls::TlsAcceptor; + +/// A plausible but wrong fingerprint (SHA-256 size, hex-uppercase, colon-separated). +const WRONG_PIN: &str = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"; + +/// Computes the expected `AA:BB:CC:...` uppercase fingerprint from raw DER. +fn fingerprint_of(der: &[u8]) -> String { + let digest = Sha256::digest(der); + let hex = hex::encode_upper(digest); + hex.as_bytes() + .chunks(2) + .map(|pair| std::str::from_utf8(pair).expect("hex digits are ASCII")) + .collect::>() + .join(":") +} + +/// Generates a self-signed certificate and key for `127.0.0.1`. +fn generate_self_signed() -> (CertificateDer<'static>, PrivateKeyDer<'static>) { + let certified = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]) + .expect("self-signed cert generation should succeed"); + let cert_der = certified.cert.der().clone(); + let key_der = + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der())); + (cert_der, key_der) +} + +/// Spawns a TLS server on an ephemeral port serving `cert_der`/`key_der` and +/// returns the `https://` URL and the expected certificate fingerprint. +async fn spawn_tls_server( + cert_der: CertificateDer<'static>, + key_der: PrivateKeyDer<'static>, +) -> (String, String) { + // rustls cannot auto-select a process-level CryptoProvider when the + // dependency graph enables both `ring` (reqwest/tokio-rustls) and the + // rustls default `aws-lc-rs`. The production capture helpers install ring + // explicitly; the spawned server below must do the same or it races with + // (and loses to) the client's install before building its config. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind should succeed"); + let address = listener.local_addr().expect("local addr should exist"); + + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der.clone()], key_der) + .expect("server config should build"); + let acceptor = Arc::new(TlsAcceptor::from(Arc::new(config))); + + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(accepted) => accepted, + Err(_) => continue, + }; + let acceptor = Arc::clone(&acceptor); + tokio::spawn(async move { + // Complete the handshake, then drop the connection. The client + // captures the certificate as soon as the handshake finishes. + let _ = acceptor.accept(stream).await; + }); + } + }); + + let expected = fingerprint_of(cert_der.as_ref()); + (format!("https://{}", address), expected) +} + +#[tokio::test(flavor = "multi_thread")] +async fn fetch_certificate_info_returns_real_certificate_data() { + let (cert_der, key_der) = generate_self_signed(); + let (url, expected) = spawn_tls_server(cert_der, key_der).await; + + let info = fetch_certificate_info(&url) + .await + .expect("certificate info should be fetched"); + + assert_eq!( + info.fingerprint, expected, + "fingerprint must match the SHA-256 of the served certificate" + ); + assert!(info.self_signed, "generated certificate is self-signed"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn capture_fingerprint_matches_served_certificate() { + let (cert_der, key_der) = generate_self_signed(); + let (url, expected) = spawn_tls_server(cert_der, key_der).await; + + let fingerprint = capture_fingerprint(&url) + .await + .expect("fingerprint should be captured"); + + assert_eq!(fingerprint, expected); +} + +#[tokio::test(flavor = "multi_thread")] +async fn verify_server_certificate_with_correct_pin_succeeds() { + let (cert_der, key_der) = generate_self_signed(); + let (url, expected) = spawn_tls_server(cert_der, key_der).await; + + let fingerprint = verify_server_certificate(&url, Some(&expected), false) + .await + .expect("matching pin should verify"); + + assert_eq!(fingerprint, expected); +} + +#[tokio::test(flavor = "multi_thread")] +async fn verify_server_certificate_with_wrong_pin_fails() { + let (cert_der, key_der) = generate_self_signed(); + let (url, _expected) = spawn_tls_server(cert_der, key_der).await; + + let error = verify_server_certificate(&url, Some(WRONG_PIN), false) + .await + .expect_err("mismatched pin should fail"); + + assert!( + matches!(error, Error::CertificateError(ref message) if message.contains("fingerprint")), + "expected CertificateError, got: {}", + error + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn verify_server_certificate_accepts_untrusted_on_mismatch() { + let (cert_der, key_der) = generate_self_signed(); + let (url, expected) = spawn_tls_server(cert_der, key_der).await; + + let fingerprint = verify_server_certificate(&url, Some(WRONG_PIN), true) + .await + .expect("accept_untrusted should bypass the pin mismatch"); + + assert_eq!(fingerprint, expected); +} + +#[tokio::test(flavor = "multi_thread")] +async fn verify_server_certificate_without_pin_returns_fingerprint() { + let (cert_der, key_der) = generate_self_signed(); + let (url, expected) = spawn_tls_server(cert_der, key_der).await; + + let fingerprint = verify_server_certificate(&url, None, false) + .await + .expect("first use should return the fingerprint"); + + assert_eq!(fingerprint, expected); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fetch_certificate_info_rejects_non_https_urls() { + let error = fetch_certificate_info("http://127.0.0.1:8006") + .await + .expect_err("http URL must be rejected"); + + assert!( + matches!(error, Error::InvalidUrl(_)), + "expected InvalidUrl, got: {}", + error + ); +} + +#[test] +fn verify_pin_is_case_insensitive_and_separator_agnostic() { + let canonical = "AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90"; + + assert!(verify_pin(canonical, canonical)); + assert!(verify_pin(canonical, &canonical.to_lowercase())); + assert!(verify_pin(&canonical.replace(':', ""), canonical)); + assert!(verify_pin(canonical, &canonical.replace(':', " "))); + assert!(!verify_pin(canonical, WRONG_PIN)); +} diff --git a/src/App.tsx b/src/App.tsx index e0f8227..226d009 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,8 @@ import { Dashboard } from '@/components/layout/Dashboard' import { ConnectionDialog } from '@/components/connections/ConnectionDialog' import { VMList } from '@/components/vms/VMList' 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 { BackupList } from '@/components/backups/BackupList' import { CommandPalette } from '@/components/command/CommandPalette' @@ -11,11 +13,13 @@ import { StorageOverview } from '@/components/storage/StorageOverview' import { StorageDetail } from '@/components/storage/StorageDetail' import { SettingsPage } from '@/components/settings/SettingsPage' import { ErrorBoundary } from '@/components/ErrorBoundary' -import { ToastProvider } from '@/components/ui/toast' +import { ToastProvider, useToast } from '@/components/ui/toast' +import { Server, AlertTriangle } from 'lucide-react' import { useConnectionStore } from '@/stores/connectionStore' import { useUIStore } from '@/stores/uiStore' import { useWebSocket } from '@/hooks/useWebSocket' -import { useEffect, useState } from 'react' +import { isTauri, loadConnections, connectToServer, getConnectionStatus, updateTrayMenu } from '@/lib/tauri' +import { useEffect, useRef, useState } from 'react' import type { ProxmoxVM } from '@/types/proxmox' const queryClient = new QueryClient({ @@ -31,6 +35,9 @@ type View = | { type: 'dashboard' } | { type: 'vms' } | { type: 'vm-detail'; vm: ProxmoxVM } + | { type: 'nodes' } + | { type: 'node-detail'; nodeName: string } + | { type: 'containers' } | { type: 'tasks' } | { type: 'backups' } | { type: 'storage' } @@ -39,17 +46,115 @@ type View = function AppContent() { const activeConnectionId = useConnectionStore((s) => s.activeConnectionId) + const connections = useConnectionStore((s) => s.connections) + const hydrate = useConnectionStore((s) => s.hydrate) + const setActiveConnection = useConnectionStore((s) => s.setActiveConnection) + const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus) + const updateConnection = useConnectionStore((s) => s.updateConnection) + const removeConnection = useConnectionStore((s) => s.removeConnection) + const setAuthStatus = useConnectionStore((s) => s.setAuthStatus) + const { addToast } = useToast() const [connectionDialogOpen, setConnectionDialogOpen] = useState(false) const [view, setView] = useState({ type: 'dashboard' }) const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen) const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen) + // In browser mock mode there is nothing to load, so the app is ready + // immediately. In Tauri mode the persisted connections load on mount. + const [connectionsLoaded, setConnectionsLoaded] = useState(!isTauri()) + const bootRan = useRef(false) - // Auto-open login dialog when no active connection + // Startup: load persisted connections and auto-reconnect the active one useEffect(() => { - if (!activeConnectionId && !connectionDialogOpen) { + if (bootRan.current) return + bootRan.current = true + + if (!isTauri()) return + + let cancelled = false + const boot = async () => { + try { + const { connections: loaded, activeConnectionId } = await loadConnections() + if (cancelled) return + hydrate(loaded, activeConnectionId) + + if (activeConnectionId && loaded.some((c) => c.id === activeConnectionId)) { + setActiveConnection(activeConnectionId) + setConnectionStatus(activeConnectionId, 'connecting') + try { + const result = await connectToServer(activeConnectionId) + if (cancelled) return + if (result.mergedInto && result.mergedInto !== activeConnectionId) { + // This connection belongs to a cluster we already have; the + // backend folded it into the surviving connection. + removeConnection(activeConnectionId) + setActiveConnection(result.mergedInto) + setConnectionStatus(result.mergedInto, result.status) + setAuthStatus('authenticated') + addToast('Already connected to this cluster — added as a failover endpoint.', 'success') + } else { + setConnectionStatus(result.connectionId, result.status) + } + } catch (err) { + if (!cancelled) { + setConnectionStatus(activeConnectionId, 'failed') + console.error('[App] Auto-reconnect failed:', err) + } + } + } + } catch (err) { + console.error('[App] Failed to load connections:', err) + } finally { + if (!cancelled) setConnectionsLoaded(true) + } + } + + boot() + return () => { + cancelled = true + } + }, [hydrate, setActiveConnection, setConnectionStatus, setAuthStatus, removeConnection, addToast]) + + // Keep the system tray menu in sync with the connection list (no-op in browser mode) + useEffect(() => { + updateTrayMenu( + connections.map((c) => ({ id: c.id, name: c.name, status: c.status })), + ) + }, [connections]) + + // 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(() => { + if (!isTauri() || !activeConnectionId) return + let cancelled = false + + const poll = async () => { + try { + const info = await getConnectionStatus(activeConnectionId) + if (cancelled) return + setConnectionStatus(activeConnectionId, info.status) + updateConnection(activeConnectionId, { + nodes: info.nodes, + currentEndpointUrl: info.currentEndpointUrl, + }) + } catch { + // Transient failures are ignored; the next tick keeps polling. + } + } + + poll() + const interval = setInterval(poll, 10_000) + return () => { + cancelled = true + clearInterval(interval) + } + }, [activeConnectionId, setConnectionStatus, updateConnection]) + + // Auto-open login dialog only when there are genuinely zero connections + useEffect(() => { + if (connectionsLoaded && connections.length === 0 && !connectionDialogOpen) { setConnectionDialogOpen(true) } - }, [activeConnectionId, connectionDialogOpen]) + }, [connectionsLoaded, connections.length, connectionDialogOpen]) // WebSocket integration – connects when a connection is active useWebSocket(activeConnectionId) @@ -77,10 +182,15 @@ function AppContent() { if (!activeConnectionId) { return ( -
-
-

ProxmoxDesktop

-

+

+
+
+ +
+

+ No connection selected +

+

Connect to a Proxmox server to get started

@@ -115,6 +225,22 @@ function AppContent() { onBack={() => handleNavigate({ type: 'vms' })} /> ) + case 'nodes': + return + case 'node-detail': + return ( + + ) + case 'containers': + return ( + handleNavigate({ type: 'vm-detail', vm })} + /> + ) case 'tasks': return case 'backups': @@ -146,16 +272,29 @@ function AppContent() { } } + const activeConnection = connections.find((c) => c.id === activeConnectionId) + return ( -
- setConnectionDialogOpen(true)} - activeView={view.type} - onNavigate={(v) => handleNavigate(v as View)} - /> -
- {renderMainContent()} -
+
+ {activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && ( +
+ +

Operating on a fallback node

+

+ {activeConnection.currentEndpointUrl} +

+
+ )} +
+ setConnectionDialogOpen(true)} + activeView={view.type} + onNavigate={(v) => handleNavigate(v as View)} + /> +
+ {renderMainContent()} +
+
{ return (
-
- -

Something went wrong

-

- {this.state.error?.message || 'An unexpected error occurred'} -

+
+
+ +
+
+

Something went wrong

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+
@@ -128,61 +132,61 @@ export function BackupList({ connectionId }: BackupListProps) { {!backupJobs || backupJobs.length === 0 ? ( -
- -

No backup jobs configured

-

Create a backup job to get started

-
+ ) : (
- - - - - - - - - - - - {backupJobs.map((job) => ( - - - - - + + + + + + + + + + + {backupJobs.map((job) => ( + + + + + + - + ) : ( + + + Disabled + + )} + @@ -264,54 +244,51 @@ export function BackupList({ connectionId }: BackupListProps) { {/* Existing Backups Section */}
-

Existing Backups

+

Existing Backups

{!filteredBackups || filteredBackups.length === 0 ? ( -
- -

No backups found

-
+ ) : (
IDScheduleStorageModeStatusActions
{job.id} -
- - {job.schedule} -
-
-
- - {job.store} -
-
- - {job.mode ?? 'snapshot'} +
IDScheduleStorageModeStatusActions
{job.id} +
+ + {job.schedule} +
+
+
+ + {job.store} +
+
+ + {job.mode ?? 'snapshot'} + + + {job.enabled === 1 ? ( + + + Enabled - - {job.enabled === 1 ? ( - - - Enabled - - ) : ( - - - Disabled - - )} -
- {confirmDeleteJob === job.id ? ( -
- - -
- ) : ( - - )} +
- - - - - - - - - - - - {filteredBackups.map((backup) => ( - - - - - - + + + + + + + + + + + + {filteredBackups.map((backup) => ( + + + + + + @@ -398,6 +351,35 @@ export function BackupList({ connectionId }: BackupListProps) { storageOptions={storageOptions} /> )} + + {/* Delete confirmations */} + { + if (!open) setConfirmDeleteJob(null) + }} + title="Delete Backup Job" + description={`Are you sure you want to delete backup job "${confirmDeleteJob}"? This action cannot be undone.`} + confirmLabel="Delete" + isLoading={deleteBackupJob.isPending} + onConfirm={() => { + if (confirmDeleteJob) deleteBackupJob.mutate({ id: confirmDeleteJob }) + }} + /> + + { + if (!open) setConfirmDeleteBackup(null) + }} + title="Delete Backup" + description={`Are you sure you want to delete this backup? This action cannot be undone.`} + confirmLabel="Delete" + isLoading={deleteBackup.isPending} + onConfirm={() => { + if (confirmDeleteBackup) deleteBackup.mutate({ volid: confirmDeleteBackup }) + }} + /> ) } diff --git a/src/components/backups/dialogs/CreateBackupJobDialog.tsx b/src/components/backups/dialogs/CreateBackupJobDialog.tsx index fe5de77..b166f64 100644 --- a/src/components/backups/dialogs/CreateBackupJobDialog.tsx +++ b/src/components/backups/dialogs/CreateBackupJobDialog.tsx @@ -142,7 +142,7 @@ export function CreateBackupJobDialog({ id="all" checked={all} onChange={(e) => setAll(e.target.checked)} - className="h-4 w-4 rounded border-input" + className="h-4 w-4 rounded border-input accent-primary" />
VMIDTypeDateSizeStorageActions
- {backup['backup-id']} - - - {backup['backup-type']} - - - {formatTimestamp(backup['backup-time'])} - - {formatBytes(backup.size)} - -
- - {backup.storage} -
-
VMIDTypeDateSizeStorageActions
+ {backup['backup-id']} + + + {backup['backup-type']} + + + {formatTimestamp(backup['backup-time'])} + + {formatBytes(backup.size)} + +
+ + {backup.storage} +
+
- {confirmDeleteBackup === backup.volid ? ( -
- - -
- ) : ( - - )} +
- - - - - - - - + + + + + + + + @@ -262,7 +257,7 @@ export function TaskList({ connectionId }: TaskListProps) { return ( toggleExpanded(task.upid)} > - + - + - - {isExpanded && ( - +
- UPIDTypeNodeUserStatusStart TimeDuration
+ UPIDTypeNodeUserStatusStart TimeDuration
@@ -272,57 +267,57 @@ export function TaskList({ connectionId }: TaskListProps) { )} {task.upid}{task.upid} {task.type} {task.node}{task.node} {task.user} +
{formatTimestamp(task.starttime)}
+ {formatDuration(task.starttime, task.endtime)}
-
+
- UPID: -

{task.upid}

+ UPID +

{task.upid}

- Task ID: -

{task.id}

+ Task ID +

{task.id}

- PID: -

{task.pid}

+ PID +

{task.pid}

- PStart: -

{task.pstart}

+ PStart +

{task.pstart}

{task.endtime && (
- End Time: -

{formatTimestamp(task.endtime)}

+ End Time +

{formatTimestamp(task.endtime)}

)} {task.exitstatus && (
- Exit Status: -

+ Exit Status +

{task.exitstatus}

diff --git a/src/components/tasks/TaskStatusBar.tsx b/src/components/tasks/TaskStatusBar.tsx index 88c7a4c..3df49b5 100644 --- a/src/components/tasks/TaskStatusBar.tsx +++ b/src/components/tasks/TaskStatusBar.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { ListTodo, Play } from 'lucide-react' +import { ListTodo, Loader2 } from 'lucide-react' import { useTasks } from '@/hooks/useProxmox' import type { ProxmoxTask } from '@/types/proxmox' @@ -27,20 +27,20 @@ export function TaskStatusBar({ connectionId, onClick }: TaskStatusBarProps) { return ( ) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 9e6e1e2..89fd44c 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -4,15 +4,20 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition duration-150 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { - default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', - outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', - secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', + default: + 'bg-primary text-primary-foreground shadow-sm shadow-primary/25 hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: + 'text-muted-foreground hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', }, size: { diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 85849b0..1134ac6 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -8,7 +8,7 @@ const Card = React.forwardRef<
(({ className, ...props }, ref) => (
)) @@ -34,7 +34,10 @@ const CardTitle = React.forwardRef< >(({ className, ...props }, ref) => (
)) @@ -56,7 +59,7 @@ const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)) CardContent.displayName = 'CardContent' @@ -66,7 +69,7 @@ const CardFooter = React.forwardRef< >(({ className, ...props }, ref) => (
)) diff --git a/src/components/ui/confirm-dialog.tsx b/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..652f118 --- /dev/null +++ b/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,63 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Loader2 } from 'lucide-react' + +interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + confirmLabel?: string + cancelLabel?: string + destructive?: boolean + isLoading?: boolean + onConfirm: () => void +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + destructive = true, + isLoading = false, + onConfirm, +}: ConfirmDialogProps) { + const handleConfirm = () => { + onConfirm() + onOpenChange(false) + } + + return ( + + + + {title} + {description && {description}} + + + + + + + + ) +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 20a3d94..8f704e1 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef< +
+ +
+

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ) +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 3bd89ba..8daa0e0 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -7,7 +7,7 @@ const Input = React.forwardRef>( - + )) ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..dd97bb7 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export function Skeleton({ className }: { className?: string }) { + return
+} + +interface PageSkeletonProps { + /** Render a search/filter bar between the header and the content blocks */ + filter?: boolean + /** Custom content blocks; defaults to a generic card layout */ + children?: ReactNode +} + +/** + * Page-level loading placeholder. Mirrors the standard layout of the + * main views (header line + optional filter bar + content blocks). + */ +export function PageSkeleton({ filter = false, children }: PageSkeletonProps) { + return ( +
+
+
+ + +
+ {filter && ( +
+ + + +
+ )} + {children ?? ( + <> +
+ {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+ + + + )} +
+
+ ) +} diff --git a/src/components/ui/status-badge.tsx b/src/components/ui/status-badge.tsx index 5d8f210..752332f 100644 --- a/src/components/ui/status-badge.tsx +++ b/src/components/ui/status-badge.tsx @@ -1,5 +1,5 @@ import { cn } from '@/lib/utils' -import { CheckCircle, XCircle, Pause, Clock } from 'lucide-react' +import { CheckCircle, Square, Pause, Clock, XCircle } from 'lucide-react' type BadgeStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'online' | 'offline' @@ -11,32 +11,32 @@ interface StatusBadgeProps { const statusConfig: Record }> = { running: { label: 'Running', - color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', + color: 'border-success/25 bg-success/10 text-success', icon: CheckCircle, }, stopped: { label: 'Stopped', - color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', - icon: XCircle, + color: 'border-border bg-muted/60 text-muted-foreground', + icon: Square, }, paused: { label: 'Paused', - color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300', + color: 'border-warning/25 bg-warning/10 text-warning', icon: Pause, }, suspended: { label: 'Suspended', - color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300', + color: 'border-warning/25 bg-warning/10 text-warning', icon: Clock, }, online: { label: 'Online', - color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', + color: 'border-success/25 bg-success/10 text-success', icon: CheckCircle, }, offline: { label: 'Offline', - color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', + color: 'border-destructive/25 bg-destructive/10 text-destructive', icon: XCircle, }, } @@ -48,7 +48,7 @@ export function StatusBadge({ status, className }: StatusBadgeProps) { return ( ; container: string }> = { +const variantStyles: Record; bar: string; iconColor: string }> = { success: { icon: CheckCircle, - container: 'bg-green-50 border-green-200 text-green-800 dark:bg-green-950 dark:border-green-800 dark:text-green-200', + bar: 'border-l-success', + iconColor: 'text-success', }, error: { icon: AlertCircle, - container: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200', + bar: 'border-l-destructive', + iconColor: 'text-destructive', }, warning: { icon: AlertTriangle, - container: 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-200', + bar: 'border-l-warning', + iconColor: 'text-warning', }, info: { icon: Info, - container: 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200', + bar: 'border-l-info', + iconColor: 'text-info', }, } @@ -100,18 +104,18 @@ function ToastItem({ return (
- -

{toast.message}

+ +

{toast.message}

diff --git a/src/components/vms/ContainerList.tsx b/src/components/vms/ContainerList.tsx index d66f904..36f8918 100644 --- a/src/components/vms/ContainerList.tsx +++ b/src/components/vms/ContainerList.tsx @@ -2,6 +2,8 @@ import { useState, useMemo } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { StatusBadge } from '@/components/ui/status-badge' +import { EmptyState } from '@/components/ui/empty-state' +import { PageSkeleton, Skeleton } from '@/components/ui/skeleton' import { Search, Server, Box } from 'lucide-react' import { useVMs } from '@/hooks/useProxmox' import type { ProxmoxVM } from '@/types/proxmox' @@ -49,9 +51,9 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP if (isLoading) { return ( -
-

Loading containers...

-
+ + + ) } @@ -68,7 +70,7 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
{/* Header */}
-

Containers

+

Containers

{containers.length} total containers across the cluster

@@ -116,44 +118,36 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP {filteredContainers.length === 0 ? ( -
- {containers.length === 0 ? ( -
- -

No containers found

-
- ) : ( -
- -

No containers match your filters

-
- )} -
+ containers.length === 0 ? ( + + ) : ( + + ) ) : (
- - - - - - - - - + + + + + + + + + {filteredContainers.map((container) => ( onContainerClick?.(container)} > - + - - - - diff --git a/src/components/vms/VMDetail.tsx b/src/components/vms/VMDetail.tsx index 0048854..8af48a3 100644 --- a/src/components/vms/VMDetail.tsx +++ b/src/components/vms/VMDetail.tsx @@ -61,7 +61,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) { const isCluster = clusterStatus.data?.type === 'cluster' && (clusterStatus.data?.nodes?.length ?? 0) > 1 const handleStart = () => { - startVM.mutate({ node: vm.node, vmid: vm.vmid }) + startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }) } const handleStop = () => { @@ -89,11 +89,11 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) { } const handleSuspend = () => { - suspendVM.mutate({ node: vm.node, vmid: vm.vmid }) + suspendVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }) } const handleResume = () => { - resumeVM.mutate({ node: vm.node, vmid: vm.vmid }) + resumeVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }) } const executeConfirmAction = () => { @@ -101,13 +101,13 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) { switch (confirmAction.type) { case 'stop': - stopVM.mutate({ node: vm.node, vmid: vm.vmid }) + stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }) break case 'shutdown': - shutdownVM.mutate({ node: vm.node, vmid: vm.vmid }) + shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }) break case 'reboot': - rebootVM.mutate({ node: vm.node, vmid: vm.vmid }) + rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }) break } @@ -125,11 +125,13 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
-

{vm.name}

+

{vm.name}

-

- VMID {vm.vmid} · {vm.type.toUpperCase()} · {vm.node} +

+ VMID {vm.vmid} ·{' '} + {vm.type.toUpperCase()} ·{' '} + {vm.node}

@@ -142,7 +144,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) { onClick={handleStart} disabled={isBusy} > - + Start ) : null} @@ -155,7 +157,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) { onClick={handleShutdown} disabled={isBusy} > - + Shutdown @@ -186,7 +188,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) { onClick={handleSuspend} disabled={isBusy} > - + Suspend )} @@ -198,7 +200,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) { onClick={handleResume} disabled={isBusy} > - + Resume )} @@ -210,7 +212,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) { onClick={() => setMigrateDialogOpen(true)} disabled={isBusy} > - + Migrate )} diff --git a/src/components/vms/VMList.tsx b/src/components/vms/VMList.tsx index 03fb428..dbd3a5a 100644 --- a/src/components/vms/VMList.tsx +++ b/src/components/vms/VMList.tsx @@ -2,6 +2,8 @@ import { useState, useMemo } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { StatusBadge } from '@/components/ui/status-badge' +import { EmptyState } from '@/components/ui/empty-state' +import { PageSkeleton, Skeleton } from '@/components/ui/skeleton' import { Search, Server, Box } from 'lucide-react' import { useVMs } from '@/hooks/useProxmox' import type { ProxmoxVM } from '@/types/proxmox' @@ -50,9 +52,9 @@ export function VMList({ connectionId, onVMClick }: VMListProps) { if (vmsLoading) { return ( -
-

Loading VMs...

-
+ + + ) } @@ -69,7 +71,7 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
{/* Header */}
-

Virtual Machines

+

Virtual Machines

{vms?.length ?? 0} total VMs across the cluster

@@ -127,45 +129,37 @@ export function VMList({ connectionId, onVMClick }: VMListProps) { {filteredVMs.length === 0 ? ( -
- {vms?.length === 0 ? ( -
- -

No VMs found

-
- ) : ( -
- -

No VMs match your filters

-
- )} -
+ vms?.length === 0 ? ( + + ) : ( + + ) ) : (
VMIDNameStatusNodeCPUMemoryDiskUptime
VMIDNameStatusNodeCPUMemoryDiskUptime
{container.vmid}{container.vmid} {container.name} @@ -161,21 +155,21 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
- {container.node} + {container.node}
+ {container.cpus} cores + {container.maxmem > 0 ? formatBytes(container.mem) : 'N/A'} / {formatBytes(container.maxmem)} + {container.maxdisk > 0 ? formatBytes(container.disk) : 'N/A'} / {formatBytes(container.maxdisk)} + {formatUptime(container.uptime)}
- - - - - - - - - - + + + + + + + + + + {filteredVMs.map((vm) => ( onVMClick?.(vm)} > - + - - - - diff --git a/src/components/vms/dialogs/AddDiskDialog.tsx b/src/components/vms/dialogs/AddDiskDialog.tsx index df550f6..9928e5b 100644 --- a/src/components/vms/dialogs/AddDiskDialog.tsx +++ b/src/components/vms/dialogs/AddDiskDialog.tsx @@ -32,6 +32,7 @@ export function AddDiskDialog({ open, onOpenChange, vm }: AddDiskDialogProps) { { node: vm.node, vmid: vm.vmid, + vmType: vm.type, config: { storage, size: sizeBytes, busType }, }, { diff --git a/src/components/vms/dialogs/AddNICDialog.tsx b/src/components/vms/dialogs/AddNICDialog.tsx index ca58d8d..7b0fe58 100644 --- a/src/components/vms/dialogs/AddNICDialog.tsx +++ b/src/components/vms/dialogs/AddNICDialog.tsx @@ -33,6 +33,7 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) { { node: vm.node, vmid: vm.vmid, + vmType: vm.type, config: { bridge, model, @@ -114,7 +115,7 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) { type="checkbox" checked={firewall} onChange={(e) => setFirewall(e.target.checked)} - className="h-4 w-4 rounded border-input" + className="h-4 w-4 rounded border-input accent-primary" />
VMIDNameStatusNodeTypeCPUMemoryDiskUptime
VMIDNameStatusNodeTypeCPUMemoryDiskUptime
{vm.vmid}{vm.vmid} {vm.name} @@ -173,7 +167,7 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
- {vm.node} + {vm.node}
@@ -181,18 +175,18 @@ export function VMList({ connectionId, onVMClick }: VMListProps) { {vm.type} + {vm.cpus} cores + {vm.maxmem > 0 ? formatBytes(vm.mem) : 'N/A'} / {formatBytes(vm.maxmem)} + {vm.maxdisk > 0 ? formatBytes(vm.disk) : 'N/A'} / {formatBytes(vm.maxdisk)} + {formatUptime(vm.uptime)}
- - - - - - - - - - - - {diskList.map((disk) => ( - - - - - - + + + + + + + + + + + + {diskList.map((disk) => ( + + + + + +
DeviceSizeStorageFormatUsageActions
{disk.device}{formatBytes(disk.size)}{disk.storage} - - {disk.format} - - - {disk.usage ?? '-'} -
DeviceSizeStorageFormatUsageActions
{disk.device}{formatBytes(disk.size)}{disk.storage} + + {disk.format} + + + {disk.usage ?? '-'} +
- - - - + { + if (!open) setDeleteDisk(null) + }} + title="Remove Disk" + description={`Are you sure you want to remove ${deleteDisk?.device} from ${vm.name}? This action cannot be undone.`} + confirmLabel="Remove" + isLoading={removeDisk.isPending} + onConfirm={handleDelete} + />
) } diff --git a/src/components/vms/tabs/HardwareTab.tsx b/src/components/vms/tabs/HardwareTab.tsx index fa0ec3d..49dadb2 100644 --- a/src/components/vms/tabs/HardwareTab.tsx +++ b/src/components/vms/tabs/HardwareTab.tsx @@ -42,7 +42,7 @@ export function HardwareTab({ vm }: HardwareTabProps) { Basic Configuration @@ -54,11 +54,11 @@ export function HardwareTab({ vm }: HardwareTabProps) {

VMID

-

{vm.vmid}

+

{vm.vmid}

Type

-

{vm.type}

+

{vm.type}

Tags

@@ -74,18 +74,18 @@ export function HardwareTab({ vm }: HardwareTabProps) {
-
+

CPU Cores

-

{vm.cpus} cores

+

{vm.cpus} cores

-
+

Memory

-

+

{formatBytes(vm.maxmem)}

@@ -102,7 +102,7 @@ export function HardwareTab({ vm }: HardwareTabProps) {

Boot Order

-

scsi0, net0

+

scsi0, net0

BIOS

@@ -166,11 +166,11 @@ export function HardwareTab({ vm }: HardwareTabProps) {
diff --git a/src/components/vms/tabs/NetworkTab.tsx b/src/components/vms/tabs/NetworkTab.tsx index a450c04..73afb07 100644 --- a/src/components/vms/tabs/NetworkTab.tsx +++ b/src/components/vms/tabs/NetworkTab.tsx @@ -1,14 +1,9 @@ import { useState } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { EmptyState } from '@/components/ui/empty-state' +import { Skeleton } from '@/components/ui/skeleton' import { Network, Plus, Pencil, Trash } from 'lucide-react' import { useNetworkInterfaces, useRemoveNIC } from '@/hooks/useProxmox' import { AddNICDialog } from '@/components/vms/dialogs/AddNICDialog' @@ -22,7 +17,7 @@ interface NetworkTabProps { } export function NetworkTab({ vm, connectionId }: NetworkTabProps) { - const { data: interfaces, isLoading, error } = useNetworkInterfaces(connectionId, vm.node, vm.vmid) + const { data: interfaces, isLoading, error } = useNetworkInterfaces(connectionId, vm.node, vm.vmid, vm.type) const removeNIC = useRemoveNIC() const [addDialogOpen, setAddDialogOpen] = useState(false) @@ -31,20 +26,20 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) { const handleDelete = () => { if (!deleteNic) return - removeNIC.mutate( - { node: vm.node, vmid: vm.vmid, nic: deleteNic.name }, - { - onSuccess: () => { - setDeleteNic(null) - }, - }, - ) + removeNIC.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, nic: deleteNic.name }) } if (isLoading) { return ( -
-

Loading network interfaces...

+
+
+
+ + +
+ +
+
) } @@ -63,10 +58,10 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
-

Network Interfaces

+

Network Interfaces

{nicList.length} NIC{nicList.length !== 1 ? 's' : ''} attached - + I/O: {formatNetworkRate(vm.netin)} in / {formatNetworkRate(vm.netout)} out

@@ -80,52 +75,49 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) { {nicList.length === 0 ? ( -
- -

No network interfaces attached

-
+ ) : (
- - - - - - - - - - - - - {nicList.map((nic) => ( - - - - - - - + + + + + + + + + + + + + {nicList.map((nic) => ( + + + + + + +
DeviceModelMAC AddressBridgeVLANFirewallActions
{nic.name} - - {nic.model} - - {nic.macaddr}{nic.bridge ?? '-'} - {nic.tag != null ? ( - {nic.tag} - ) : ( - '-' - )} - - {nic.firewall === 1 ? ( - - ) : ( - - )} -
DeviceModelMAC AddressBridgeVLANFirewallActions
{nic.name} + + {nic.model} + + {nic.macaddr}{nic.bridge ?? '-'} + {nic.tag != null ? ( + {nic.tag} + ) : ( + '-' + )} + + {nic.firewall === 1 ? ( + + ) : ( + + )} +
- - - - + { + if (!open) setDeleteNic(null) + }} + title="Remove Network Interface" + description={`Are you sure you want to remove ${deleteNic?.name} from ${vm.name}? This action cannot be undone.`} + confirmLabel="Remove" + isLoading={removeNIC.isPending} + onConfirm={handleDelete} + />
) } diff --git a/src/components/vms/tabs/OverviewTab.tsx b/src/components/vms/tabs/OverviewTab.tsx index 1c10c9b..ea059d5 100644 --- a/src/components/vms/tabs/OverviewTab.tsx +++ b/src/components/vms/tabs/OverviewTab.tsx @@ -18,6 +18,11 @@ function ResourceBar({ label, used, total, icon: Icon }: { const displayUsed = label === 'CPU' ? used.toFixed(1) : formatBytes(used) const displayTotal = label === 'CPU' ? `${total} cores` : formatBytes(total) + const barColor = + percent > 90 ? 'bg-destructive' : + percent > 70 ? 'bg-warning' : + 'bg-success' + return (
@@ -25,25 +30,17 @@ function ResourceBar({ label, used, total, icon: Icon }: { {label}
- + {displayUsed} / {displayTotal}
90 - ? 'hsl(var(--destructive))' - : percent > 70 - ? 'hsl(38, 92%, 50%)' - : 'hsl(142, 71%, 45%)', - }} + className={`h-full rounded-full transition-all duration-500 ${barColor}`} + style={{ width: `${Math.min(percent, 100)}%` }} />
-

+

{percent.toFixed(1)}%

@@ -88,7 +85,9 @@ export function OverviewTab({ vm }: OverviewTabProps) { -
{vm.vmid}
+
+ {vm.vmid} +
@@ -98,7 +97,9 @@ export function OverviewTab({ vm }: OverviewTabProps) { -
{vm.type}
+
+ {vm.type} +
@@ -108,7 +109,9 @@ export function OverviewTab({ vm }: OverviewTabProps) { -
{formatUptime(vm.uptime)}
+
+ {formatUptime(vm.uptime)} +
@@ -118,7 +121,9 @@ export function OverviewTab({ vm }: OverviewTabProps) { -
{vm.node}
+
+ {vm.node} +
@@ -128,8 +133,10 @@ export function OverviewTab({ vm }: OverviewTabProps) { -
192.168.1.{100 + (vm.vmid % 100)}
-

Mock data

+
+ 192.168.1.{100 + (vm.vmid % 100)} +
+

Mock data

@@ -142,11 +149,11 @@ export function OverviewTab({ vm }: OverviewTabProps) {
In: - {formatNetworkRate(vm.netin)} + {formatNetworkRate(vm.netin)}
Out: - {formatNetworkRate(vm.netout)} + {formatNetworkRate(vm.netout)}
diff --git a/src/components/vms/tabs/SnapshotsTab.tsx b/src/components/vms/tabs/SnapshotsTab.tsx index db79a10..57e4ed4 100644 --- a/src/components/vms/tabs/SnapshotsTab.tsx +++ b/src/components/vms/tabs/SnapshotsTab.tsx @@ -9,6 +9,9 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { EmptyState } from '@/components/ui/empty-state' +import { Skeleton } from '@/components/ui/skeleton' import { Camera, Plus, Trash, RotateCcw } from 'lucide-react' import { useSnapshots, useDeleteSnapshot, useRollbackSnapshot } from '@/hooks/useProxmox' import { CreateSnapshotDialog } from '@/components/vms/dialogs/CreateSnapshotDialog' @@ -24,7 +27,7 @@ function formatDate(timestamp: number): string { } export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) { - const { data: snapshots, isLoading, error } = useSnapshots(connectionId, vm.node, vm.vmid) + const { data: snapshots, isLoading, error } = useSnapshots(connectionId, vm.node, vm.vmid, vm.type) const deleteSnapshot = useDeleteSnapshot() const rollbackSnapshot = useRollbackSnapshot() @@ -34,20 +37,13 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) { const handleDelete = () => { if (!deleteTarget) return - deleteSnapshot.mutate( - { node: vm.node, vmid: vm.vmid, name: deleteTarget.name }, - { - onSuccess: () => { - setDeleteTarget(null) - }, - }, - ) + deleteSnapshot.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, name: deleteTarget.name }) } const handleRollback = () => { if (!rollbackTarget) return rollbackSnapshot.mutate( - { node: vm.node, vmid: vm.vmid, name: rollbackTarget.name }, + { node: vm.node, vmid: vm.vmid, vmType: vm.type, name: rollbackTarget.name }, { onSuccess: () => { setRollbackTarget(null) @@ -58,8 +54,15 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) { if (isLoading) { return ( -
-

Loading snapshots...

+
+
+
+ + +
+ +
+
) } @@ -78,7 +81,7 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
-

Snapshots

+

Snapshots

{snapshotList.length} snapshot{snapshotList.length !== 1 ? 's' : ''}

@@ -92,51 +95,51 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) { {snapshotList.length === 0 ? ( -
- -

No snapshots

-

Create a snapshot to save the current VM state

-
+ ) : (
- - - - - - - - - - - - {snapshotList.map((snapshot) => ( - - - - - - + + + + + + + + + + + + {snapshotList.map((snapshot) => ( + + + + + +
NameDescriptionCreatedVM StateParentActions
{snapshot.name} - {snapshot.description || '-'} - - {formatDate(snapshot.snaptime)} - - - {snapshot.vmstate === 1 ? 'Included' : 'Not included'} - - - {snapshot.parent ?? '-'} -
NameDescriptionCreatedVM StateParentActions
{snapshot.name} + {snapshot.description || '-'} + + {formatDate(snapshot.snaptime)} + + + {snapshot.vmstate === 1 ? 'Included' : 'Not included'} + + + {snapshot.parent ?? '-'} +
- - - - + { + if (!open) setDeleteTarget(null) + }} + title="Delete Snapshot" + description={`Are you sure you want to delete snapshot "${deleteTarget?.name}"? This action cannot be undone.`} + confirmLabel="Delete" + isLoading={deleteSnapshot.isPending} + onConfirm={handleDelete} + /> {/* Rollback confirmation */} { if (!open) setRollbackTarget(null) }}> @@ -207,7 +199,7 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) { Are you sure you want to rollback {vm.name} to snapshot "{rollbackTarget?.name}"? {vm.status === 'running' && ( - + The VM will be stopped during the rollback process. )} diff --git a/src/hooks/useProxmox.ts b/src/hooks/useProxmox.ts index e1002ba..946387f 100644 --- a/src/hooks/useProxmox.ts +++ b/src/hooks/useProxmox.ts @@ -92,11 +92,12 @@ export const useStartVM = () => { const { addToast } = useToast() return useMutation({ - mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { + mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.startVM(connId, node, vmid) + return api.startVM(connId, node, vmid, vmType) }, onSuccess: () => { + addToast('VM started', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) @@ -113,11 +114,12 @@ export const useStopVM = () => { const { addToast } = useToast() return useMutation({ - mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { + mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.stopVM(connId, node, vmid) + return api.stopVM(connId, node, vmid, vmType) }, onSuccess: () => { + addToast('VM stopped', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) @@ -134,11 +136,12 @@ export const useShutdownVM = () => { const { addToast } = useToast() return useMutation({ - mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { + mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.shutdownVM(connId, node, vmid) + return api.shutdownVM(connId, node, vmid, vmType) }, onSuccess: () => { + addToast('VM shut down', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) @@ -155,11 +158,12 @@ export const useRebootVM = () => { const { addToast } = useToast() return useMutation({ - mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { + mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.rebootVM(connId, node, vmid) + return api.rebootVM(connId, node, vmid, vmType) }, onSuccess: () => { + addToast('VM rebooting', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) @@ -176,11 +180,12 @@ export const useSuspendVM = () => { const { addToast } = useToast() return useMutation({ - mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { + mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.suspendVM(connId, node, vmid) + return api.suspendVM(connId, node, vmid, vmType) }, onSuccess: () => { + addToast('VM suspended', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) @@ -197,11 +202,12 @@ export const useResumeVM = () => { const { addToast } = useToast() return useMutation({ - mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { + mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.resumeVM(connId, node, vmid) + return api.resumeVM(connId, node, vmid, vmType) }, onSuccess: () => { + addToast('VM resumed', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) @@ -214,10 +220,10 @@ export const useResumeVM = () => { } // Disk management hooks -export const useDisks = (connectionId: string, node: string, vmid: number) => { +export const useDisks = (connectionId: string, node: string, vmid: number, vmType: string) => { return useQuery({ queryKey: queryKeys.disks(connectionId, node, vmid), - queryFn: () => api.getDisks(connectionId, node, vmid), + queryFn: () => api.getDisks(connectionId, node, vmid, vmType), enabled: !!connectionId && !!node && vmid > 0, }) } @@ -230,16 +236,19 @@ export const useAddDisk = () => { mutationFn: ({ node, vmid, + vmType, config, }: { node: string vmid: number + vmType: string config: AddDiskConfig }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.addDisk(connId, node, vmid, config) + return api.addDisk(connId, node, vmid, vmType, config) }, onSuccess: (_data, variables) => { + addToast('Disk added', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -261,18 +270,21 @@ export const useResizeDisk = () => { mutationFn: ({ node, vmid, + vmType, disk, size, }: { node: string vmid: number + vmType: string disk: string size: number }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.resizeDisk(connId, node, vmid, disk, size) + return api.resizeDisk(connId, node, vmid, vmType, disk, size) }, onSuccess: (_data, variables) => { + addToast('Disk resized', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -294,16 +306,19 @@ export const useRemoveDisk = () => { mutationFn: ({ node, vmid, + vmType, disk, }: { node: string vmid: number + vmType: string disk: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.removeDisk(connId, node, vmid, disk) + return api.removeDisk(connId, node, vmid, vmType, disk) }, onSuccess: (_data, variables) => { + addToast('Disk removed', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -325,18 +340,21 @@ export const useMoveDisk = () => { mutationFn: ({ node, vmid, + vmType, disk, storage, }: { node: string vmid: number + vmType: string disk: string storage: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.moveDisk(connId, node, vmid, disk, storage) + return api.moveDisk(connId, node, vmid, vmType, disk, storage) }, onSuccess: (_data, variables) => { + addToast('Disk moved', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -351,10 +369,10 @@ export const useMoveDisk = () => { } // Network management hooks -export const useNetworkInterfaces = (connectionId: string, node: string, vmid: number) => { +export const useNetworkInterfaces = (connectionId: string, node: string, vmid: number, vmType: string) => { return useQuery({ queryKey: queryKeys.networks(connectionId, node, vmid), - queryFn: () => api.getNetworkInterfaces(connectionId, node, vmid), + queryFn: () => api.getNetworkInterfaces(connectionId, node, vmid, vmType), enabled: !!connectionId && !!node && vmid > 0, }) } @@ -367,16 +385,19 @@ export const useAddNIC = () => { mutationFn: ({ node, vmid, + vmType, config, }: { node: string vmid: number + vmType: string config: AddNICConfig }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.addNIC(connId, node, vmid, config) + return api.addNIC(connId, node, vmid, vmType, config) }, onSuccess: (_data, variables) => { + addToast('Network interface added', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -398,18 +419,21 @@ export const useEditNIC = () => { mutationFn: ({ node, vmid, + vmType, nic, config, }: { node: string vmid: number + vmType: string nic: string config: EditNICConfig }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.editNIC(connId, node, vmid, nic, config) + return api.editNIC(connId, node, vmid, vmType, nic, config) }, onSuccess: (_data, variables) => { + addToast('Network interface updated', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -431,16 +455,19 @@ export const useRemoveNIC = () => { mutationFn: ({ node, vmid, + vmType, nic, }: { node: string vmid: number + vmType: string nic: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.removeNIC(connId, node, vmid, nic) + return api.removeNIC(connId, node, vmid, vmType, nic) }, onSuccess: (_data, variables) => { + addToast('Network interface removed', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -455,10 +482,10 @@ export const useRemoveNIC = () => { } // Snapshot management hooks -export const useSnapshots = (connectionId: string, node: string, vmid: number) => { +export const useSnapshots = (connectionId: string, node: string, vmid: number, vmType: string) => { return useQuery({ queryKey: queryKeys.snapshots(connectionId, node, vmid), - queryFn: () => api.getSnapshots(connectionId, node, vmid), + queryFn: () => api.getSnapshots(connectionId, node, vmid, vmType), enabled: !!connectionId && !!node && vmid > 0, }) } @@ -471,16 +498,19 @@ export const useCreateSnapshot = () => { mutationFn: ({ node, vmid, + vmType, config, }: { node: string vmid: number + vmType: string config: CreateSnapshotConfig }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.createSnapshot(connId, node, vmid, config) + return api.createSnapshot(connId, node, vmid, vmType, config) }, onSuccess: (_data, variables) => { + addToast('Snapshot created', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -502,16 +532,19 @@ export const useDeleteSnapshot = () => { mutationFn: ({ node, vmid, + vmType, name, }: { node: string vmid: number + vmType: string name: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.deleteSnapshot(connId, node, vmid, name) + return api.deleteSnapshot(connId, node, vmid, vmType, name) }, onSuccess: (_data, variables) => { + addToast('Snapshot deleted', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -533,16 +566,19 @@ export const useRollbackSnapshot = () => { mutationFn: ({ node, vmid, + vmType, name, }: { node: string vmid: number + vmType: string name: string }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.rollbackSnapshot(connId, node, vmid, name) + return api.rollbackSnapshot(connId, node, vmid, vmType, name) }, onSuccess: (_data, variables) => { + addToast('Snapshot restored', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ @@ -565,18 +601,21 @@ export const useMigrateVM = () => { mutationFn: ({ node, vmid, + vmType, targetNode, online, }: { node: string vmid: number + vmType: string targetNode: string online: boolean }) => { const connId = useConnectionStore.getState().activeConnectionId! - return api.migrateVM(connId, node, vmid, targetNode, online) + return api.migrateVM(connId, node, vmid, vmType, targetNode, online) }, onSuccess: () => { + addToast('Migration started', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) @@ -634,6 +673,7 @@ export const useCreateBackupJob = () => { return api.createBackupJob(connId, config) }, onSuccess: () => { + addToast('Backup job created', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) }) @@ -655,6 +695,7 @@ export const useUpdateBackupJob = () => { return api.updateBackupJob(connId, id, config) }, onSuccess: () => { + addToast('Backup job updated', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) }) @@ -676,6 +717,7 @@ export const useDeleteBackupJob = () => { return api.deleteBackupJob(connId, id) }, onSuccess: () => { + addToast('Backup job deleted', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) }) @@ -697,6 +739,7 @@ export const useRunBackup = () => { return api.runBackup(connId, config) }, onSuccess: () => { + addToast('Backup started', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connId) }) @@ -718,6 +761,7 @@ export const useRestoreBackup = () => { return api.restoreBackup(connId, volid, config) }, onSuccess: () => { + addToast('Restore started', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) @@ -740,6 +784,7 @@ export const useDeleteBackup = () => { return api.deleteBackup(connId, volid) }, onSuccess: () => { + addToast('Backup deleted', 'success') const connId = useConnectionStore.getState().activeConnectionId if (connId) { queryClient.invalidateQueries({ queryKey: queryKeys.backups(connId) }) diff --git a/src/index.css b/src/index.css index 0c0085e..78ca4ec 100644 --- a/src/index.css +++ b/src/index.css @@ -1,56 +1,166 @@ @import 'tailwindcss'; @theme { - --color-background: oklch(1 0 0); - --color-foreground: oklch(0.145 0 0); - --color-card: oklch(1 0 0); - --color-card-foreground: oklch(0.145 0 0); - --color-popover: oklch(1 0 0); - --color-popover-foreground: oklch(0.145 0 0); - --color-primary: oklch(0.205 0 0); - --color-primary-foreground: oklch(0.985 0 0); - --color-secondary: oklch(0.97 0 0); - --color-secondary-foreground: oklch(0.205 0 0); - --color-muted: oklch(0.97 0 0); - --color-muted-foreground: oklch(0.556 0 0); - --color-accent: oklch(0.97 0 0); - --color-accent-foreground: oklch(0.205 0 0); - --color-destructive: oklch(0.577 0.245 27.325); - --color-destructive-foreground: oklch(0.577 0.245 27.325); - --color-border: oklch(0.922 0 0); - --color-input: oklch(0.922 0 0); - --color-ring: oklch(0.708 0 0); + /* ------------------------------------------------------------------ + * 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; + + /* ------------------------------------------------------------------ + * Light theme (default) — warm paper neutrals, cool ink, Proxmox accent + * ------------------------------------------------------------------ */ + --color-background: oklch(0.972 0.004 85); + --color-foreground: oklch(0.24 0.012 250); + --color-card: oklch(0.998 0.002 85); + --color-card-foreground: oklch(0.24 0.012 250); + --color-popover: oklch(1 0 0); + --color-popover-foreground: oklch(0.24 0.012 250); + --color-primary: oklch(0.62 0.15 55); + --color-primary-foreground: oklch(0.985 0.008 60); + --color-secondary: oklch(0.95 0.005 85); + --color-secondary-foreground: oklch(0.3 0.012 250); + --color-muted: oklch(0.95 0.005 85); + --color-muted-foreground: oklch(0.51 0.014 250); + --color-accent: oklch(0.935 0.008 85); + --color-accent-foreground: oklch(0.28 0.012 250); + --color-destructive: oklch(0.56 0.19 25); + --color-destructive-foreground: oklch(0.98 0.008 25); + --color-border: oklch(0.905 0.006 85); + --color-input: oklch(0.885 0.007 85); + --color-ring: oklch(0.62 0.15 55); + + /* Semantic status colors — data semantics, not UI accents */ + --color-success: oklch(0.55 0.13 155); + --color-warning: oklch(0.62 0.13 80); + --color-info: oklch(0.55 0.11 245); + + /* Tinted card shadow — carries the warm hue of the surface */ + --shadow-card: + 0 1px 2px 0 oklch(0.3 0.02 60 / 0.06), 0 2px 8px -2px oklch(0.3 0.02 60 / 0.05); } .dark { - --color-background: oklch(0.145 0 0); - --color-foreground: oklch(0.985 0 0); - --color-card: oklch(0.145 0 0); - --color-card-foreground: oklch(0.985 0 0); - --color-popover: oklch(0.145 0 0); - --color-popover-foreground: oklch(0.985 0 0); - --color-primary: oklch(0.985 0 0); - --color-primary-foreground: oklch(0.205 0 0); - --color-secondary: oklch(0.269 0 0); - --color-secondary-foreground: oklch(0.985 0 0); - --color-muted: oklch(0.269 0 0); - --color-muted-foreground: oklch(0.708 0 0); - --color-accent: oklch(0.269 0 0); - --color-accent-foreground: oklch(0.985 0 0); - --color-destructive: oklch(0.577 0.245 27.325); - --color-destructive-foreground: oklch(0.577 0.245 27.325); - --color-border: oklch(0.269 0 0); - --color-input: oklch(0.269 0 0); - --color-ring: oklch(0.439 0 0); + /* ------------------------------------------------------------------ + * Dark theme — blue-black ink surfaces, cool neutrals, muted orange + * ------------------------------------------------------------------ */ + --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); + + --color-success: oklch(0.7 0.14 155); + --color-warning: oklch(0.76 0.13 80); + --color-info: oklch(0.68 0.11 245); + + --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: light; + } + + html.dark { + color-scheme: dark; + } + body { - @apply bg-background text-foreground; + @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: 9999; + pointer-events: none; + opacity: 0.02; + 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; + } + + html.dark body::after { + opacity: 0.04; } } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 45dc922..dec64fb 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -1,7 +1,14 @@ // Tauri IPC commands interface // These functions call into the Rust backend via Tauri's invoke mechanism -import type { ConnectionConfig, CertificateInfo, LoginResult } from '@/types/connection' +import type { + ConnectionConfig, + CertificateInfo, + LoginResult, + LoadConnectionsResult, + ConnectResult, + ConnectionStatusInfo, +} from '@/types/connection' import type { ProxmoxNode, ProxmoxVM, @@ -36,6 +43,14 @@ const mockResponse = (data: T): Promise => { } // Connection management +export const loadConnections = async (): Promise => { + if (!isTauri()) { + return mockResponse({ activeConnectionId: null, connections: [] }) + } + const { invoke } = await import('@tauri-apps/api/core') + return invoke('load_connections') +} + export const addConnection = async (config: ConnectionConfig): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') @@ -48,18 +63,48 @@ export const removeConnection = async (id: string): Promise => { return invoke('remove_connection', { id }) } -export const connectToServer = async (id: string): Promise => { +export const updateConnection = async (config: ConnectionConfig): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') + return invoke('update_connection', { config }) +} + +export const connectToServer = async (id: string): Promise => { + if (!isTauri()) { + return mockResponse({ connectionId: id, mergedInto: null, status: 'connected' }) + } + const { invoke } = await import('@tauri-apps/api/core') return invoke('connect_to_server', { id }) } +export const getConnectionStatus = async ( + connectionId: string, +): Promise => { + if (!isTauri()) { + return mockResponse({ + connectionId, + status: 'connected', + primaryUrl: '', + currentEndpointUrl: '', + nodes: [], + }) + } + const { invoke } = await import('@tauri-apps/api/core') + return invoke('get_connection_status', { connectionId }) +} + export const disconnectFromServer = async (id: string): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') return invoke('disconnect_from_server', { id }) } +export const setActiveConnection = async (id: string): Promise => { + if (!isTauri()) return mockResponse(undefined) + const { invoke } = await import('@tauri-apps/api/core') + return invoke('set_active_connection', { id }) +} + // Authentication export const loginWithPassword = async ( url: string, @@ -222,40 +267,40 @@ export const getClusterStatus = async (connectionId: string): Promise => { +export const startVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('start_vm', { connectionId, node, vmid }) + return invoke('start_vm', { connectionId, node, vmid, vmType }) } -export const stopVM = async (connectionId: string, node: string, vmid: number): Promise => { +export const stopVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('stop_vm', { connectionId, node, vmid }) + return invoke('stop_vm', { connectionId, node, vmid, vmType }) } -export const shutdownVM = async (connectionId: string, node: string, vmid: number): Promise => { +export const shutdownVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('shutdown_vm', { connectionId, node, vmid }) + return invoke('shutdown_vm', { connectionId, node, vmid, vmType }) } -export const rebootVM = async (connectionId: string, node: string, vmid: number): Promise => { +export const rebootVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('reboot_vm', { connectionId, node, vmid }) + return invoke('reboot_vm', { connectionId, node, vmid, vmType }) } -export const suspendVM = async (connectionId: string, node: string, vmid: number): Promise => { +export const suspendVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('suspend_vm', { connectionId, node, vmid }) + return invoke('suspend_vm', { connectionId, node, vmid, vmType }) } -export const resumeVM = async (connectionId: string, node: string, vmid: number): Promise => { +export const resumeVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('resume_vm', { connectionId, node, vmid }) + return invoke('resume_vm', { connectionId, node, vmid, vmType }) } // Disk management @@ -263,6 +308,7 @@ export const getDisks = async ( connectionId: string, node: string, vmid: number, + vmType: string, ): Promise => { if (!isTauri()) { return mockResponse([ @@ -271,53 +317,57 @@ export const getDisks = async ( ]) } const { invoke } = await import('@tauri-apps/api/core') - return invoke('get_disks', { connectionId, node, vmid }) + return invoke('get_disks', { connectionId, node, vmid, vmType }) } export const addDisk = async ( connectionId: string, node: string, vmid: number, + vmType: string, config: AddDiskConfig, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('add_disk', { connectionId, node, vmid, config }) + return invoke('add_disk', { connectionId, node, vmid, vmType, config }) } export const resizeDisk = async ( connectionId: string, node: string, vmid: number, + vmType: string, disk: string, size: number, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('resize_disk', { connectionId, node, vmid, disk, size }) + return invoke('resize_disk', { connectionId, node, vmid, vmType, disk, size }) } export const removeDisk = async ( connectionId: string, node: string, vmid: number, + vmType: string, disk: string, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('remove_disk', { connectionId, node, vmid, disk }) + return invoke('remove_disk', { connectionId, node, vmid, vmType, disk }) } export const moveDisk = async ( connectionId: string, node: string, vmid: number, + vmType: string, disk: string, storage: string, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('move_disk', { connectionId, node, vmid, disk, storage }) + return invoke('move_disk', { connectionId, node, vmid, vmType, disk, storage }) } // Network management @@ -325,6 +375,7 @@ export const getNetworkInterfaces = async ( connectionId: string, node: string, vmid: number, + vmType: string, ): Promise => { if (!isTauri()) { return mockResponse([ @@ -332,41 +383,44 @@ export const getNetworkInterfaces = async ( ]) } const { invoke } = await import('@tauri-apps/api/core') - return invoke('get_network_interfaces', { connectionId, node, vmid }) + return invoke('get_network_interfaces', { connectionId, node, vmid, vmType }) } export const addNIC = async ( connectionId: string, node: string, vmid: number, + vmType: string, config: AddNICConfig, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('add_nic', { connectionId, node, vmid, config }) + return invoke('add_nic', { connectionId, node, vmid, vmType, config }) } export const editNIC = async ( connectionId: string, node: string, vmid: number, + vmType: string, nic: string, config: EditNICConfig, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('edit_nic', { connectionId, node, vmid, nic, config }) + return invoke('edit_nic', { connectionId, node, vmid, vmType, nic, config }) } export const removeNIC = async ( connectionId: string, node: string, vmid: number, + vmType: string, nic: string, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('remove_nic', { connectionId, node, vmid, nic }) + return invoke('remove_nic', { connectionId, node, vmid, vmType, nic }) } // Snapshot management @@ -374,43 +428,47 @@ export const getSnapshots = async ( connectionId: string, node: string, vmid: number, + vmType: string, ): Promise => { if (!isTauri()) return mockResponse([]) const { invoke } = await import('@tauri-apps/api/core') - return invoke('get_snapshots', { connectionId, node, vmid }) + return invoke('get_snapshots', { connectionId, node, vmid, vmType }) } export const createSnapshot = async ( connectionId: string, node: string, vmid: number, + vmType: string, config: CreateSnapshotConfig, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('create_snapshot', { connectionId, node, vmid, config }) + return invoke('create_snapshot', { connectionId, node, vmid, vmType, config }) } export const deleteSnapshot = async ( connectionId: string, node: string, vmid: number, + vmType: string, name: string, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('delete_snapshot', { connectionId, node, vmid, name }) + return invoke('delete_snapshot', { connectionId, node, vmid, vmType, name }) } export const rollbackSnapshot = async ( connectionId: string, node: string, vmid: number, + vmType: string, name: string, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('rollback_snapshot', { connectionId, node, vmid, name }) + return invoke('rollback_snapshot', { connectionId, node, vmid, vmType, name }) } // VM migration @@ -418,12 +476,13 @@ export const migrateVM = async ( connectionId: string, node: string, vmid: number, + vmType: string, targetNode: string, online: boolean, ): Promise => { if (!isTauri()) return mockResponse(undefined) const { invoke } = await import('@tauri-apps/api/core') - return invoke('migrate_vm', { connectionId, node, vmid, targetNode, online }) + return invoke('migrate_vm', { connectionId, node, vmid, vmType, targetNode, online }) } // Console proxy @@ -565,3 +624,12 @@ export const deleteBackup = async (connectionId: string, volid: string): Promise const { invoke } = await import('@tauri-apps/api/core') return invoke('delete_backup', { connectionId, volid }) } + +// Tray menu +export const updateTrayMenu = async ( + connections: { id: string; name: string; status: string }[], +): Promise => { + if (!isTauri()) return + const { invoke } = await import('@tauri-apps/api/core') + return invoke('update_tray_menu', { connections }) +} diff --git a/src/main.tsx b/src/main.tsx index bef5202..2a64ba3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import '@fontsource-variable/geist' +import '@fontsource-variable/jetbrains-mono' import './index.css' import App from './App.tsx' diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 896f52d..8e53694 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import type { ConnectionConfig, ConnectionStatus } from '@/types/connection' +import type { ConnectionConfig, ConnectionStatus, DiscoveredNode } from '@/types/connection' export type AuthStatus = 'authenticated' | 'expired' | 'unauthenticated' @@ -14,8 +14,10 @@ interface ConnectionState { addConnection: (config: ConnectionConfig) => void removeConnection: (id: string) => void updateConnection: (id: string, updates: Partial) => void + hydrate: (connections: ConnectionConfig[], activeConnectionId: string | null) => void setActiveConnection: (id: string | null) => void setConnectionStatus: (id: string, status: ConnectionStatus) => void + setConnectionNodes: (id: string, nodes: DiscoveredNode[]) => void setLoading: (loading: boolean) => void setError: (error: string | null) => void setAuthStatus: (status: AuthStatus) => void @@ -47,6 +49,9 @@ export const useConnectionStore = create((set) => ({ ), })), + hydrate: (connections, activeConnectionId) => + set({ connections, activeConnectionId }), + setActiveConnection: (id) => set({ activeConnectionId: id }), @@ -57,6 +62,13 @@ export const useConnectionStore = create((set) => ({ ), })), + setConnectionNodes: (id, nodes) => + set((state) => ({ + connections: state.connections.map((c) => + c.id === id ? { ...c, nodes } : c + ), + })), + setLoading: (loading) => set({ isLoading: loading }), setError: (error) => set({ error }), diff --git a/src/types/connection.ts b/src/types/connection.ts index 0136310..ffc6b51 100644 --- a/src/types/connection.ts +++ b/src/types/connection.ts @@ -9,11 +9,47 @@ export interface ConnectionConfig { fallbacks: EndpointConfig[] certFingerprint?: string trusted: boolean + acceptUntrusted?: boolean status: ConnectionStatus clusterName?: string + clusterId?: string isCluster: boolean authMode: AuthMode username?: string + /** Nodes discovered in the cluster this connection is anchored on. */ + nodes?: DiscoveredNode[] + /** The endpoint currently serving this connection (after failover). */ + currentEndpointUrl?: string +} + +/** A node discovered in the cluster connected through a connection. */ +export interface DiscoveredNode { + name: string + url: string + status: 'online' | 'offline' + isPrimary: boolean + local: boolean +} + +/** The outcome of a connect attempt, returned by `connect_to_server`. */ +export interface ConnectResult { + connectionId: string + mergedInto: string | null + status: 'connected' | 'failover' | 'failed' +} + +/** A snapshot of a connection's runtime state, returned by `get_connection_status`. */ +export interface ConnectionStatusInfo { + connectionId: string + status: 'connected' | 'failover' | 'failed' | 'disconnected' + primaryUrl: string + currentEndpointUrl: string + nodes: DiscoveredNode[] +} + +export interface LoadConnectionsResult { + activeConnectionId: string | null + connections: ConnectionConfig[] } export interface EndpointConfig {