diff --git a/.agents/skills/ui-api-decoupling/SKILL.md b/.agents/skills/ui-api-decoupling/SKILL.md index 6bb2e0ce..eb01c27d 100644 --- a/.agents/skills/ui-api-decoupling/SKILL.md +++ b/.agents/skills/ui-api-decoupling/SKILL.md @@ -171,7 +171,7 @@ For any shared UI call to `/api/*`, decide the VS Code behavior explicitly: Electron exposes API base and shell identity broadly, but privileged local capabilities stay local-only. - `__OPENCHAMBER_API_BASE_URL__` and `__OPENCHAMBER_LOCAL_ORIGIN__` route requests. -- `__OPENCHAMBER_CLIENT_TOKEN__`, `__OPENCHAMBER_HOME__`, and `__TAURI__`-style IPC are local-page gated. +- `__OPENCHAMBER_CLIENT_TOKEN__`, `__OPENCHAMBER_HOME__`, and privileged desktop IPC are local-page gated. - Do not expose filesystem, shell, or host secrets to remote pages for UI convenience. - Do not trust arbitrary loopback, `file://`, or `about:blank` origins as local UI. Gate privileged preload/IPC/token access to the packaged UI origin and exact runtime origins. - Deep-links that add or switch remote runtimes are trust-boundary changes. Confirm before storing tokens or switching hosts. @@ -274,9 +274,9 @@ If an OpenChamber route is consumed directly by the browser with `oc_url_token`, `packages/electron/main.mjs` starts the web server in-process, resolves local/remote runtime target, tracks `apiBaseUrl` and `clientToken`, injects init scripts, confirms remote connect deep-links before storing tokens, and handles host switching. -`packages/electron/preload.mjs` exposes runtime globals. API base and local origin are broadly available for routing. Client token, home directory, and `__TAURI__` IPC stay local-page gated so remote pages cannot access local host capabilities. +`packages/electron/preload.mjs` exposes runtime globals. API base and local origin are broadly available for routing. Client token, home directory, and privileged desktop IPC stay local-page gated so remote pages cannot access local host capabilities. -Shared UI should not branch on Electron for backend behavior. Prefer web runtime APIs and the preload-provided `__TAURI__` compatibility shim only for shell capabilities that already exist in the shared runtime contract. +Shared UI should not branch on Electron for backend behavior. Prefer web runtime APIs and the `__OPENCHAMBER_DESKTOP__` bridge only for shell capabilities that already exist in the shared runtime contract. ### Runtime Switch Flow diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e14cf22..6cf51a4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -242,32 +242,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Stage signed Electron app for Tauri updater repackage - run: | - set -euo pipefail - - APP_DIR="packages/electron/dist/mac" - [ -d "packages/electron/dist/mac-arm64" ] && APP_DIR="packages/electron/dist/mac-arm64" - - APP_PATH=$(find "$APP_DIR" -maxdepth 2 -name "*.app" -print -quit) - if [ -z "$APP_PATH" ]; then - echo "Error: .app not found under packages/electron/dist/mac*" - ls -la packages/electron/dist/ - exit 1 - fi - - rm -rf electron-app-artifact - mkdir -p electron-app-artifact - cp -R "$APP_PATH" electron-app-artifact/OpenChamber.app - tar -C electron-app-artifact -czf electron-app-${{ matrix.arch }}.tar.gz OpenChamber.app - - - name: Upload signed Electron app for Tauri updater repackage - uses: actions/upload-artifact@v7 - with: - name: electron-app-${{ matrix.arch }} - path: electron-app-${{ matrix.arch }}.tar.gz - retention-days: 1 - - name: Upload per-arch latest-mac.yml for merge uses: actions/upload-artifact@v7 with: @@ -338,179 +312,6 @@ jobs: path: packages/electron/dist/latest.yml retention-days: 1 - repackage-electron-as-tauri-update: - needs: [create-release, build-desktop-electron-macos] - runs-on: macos-26 - strategy: - fail-fast: false - matrix: - include: - - arch: arm64 - platform: darwin-aarch64 - - arch: x64 - platform: darwin-x86_64 - steps: - - uses: actions/checkout@v6 - - - name: Setup bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Download signed Electron app - uses: actions/download-artifact@v4 - with: - name: electron-app-${{ matrix.arch }} - path: staged - - - name: Tar and sign Electron app as Tauri update payload - env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - VERSION: ${{ needs.create-release.outputs.version }} - run: | - set -euo pipefail - - if [ -f staged/electron-app-${{ matrix.arch }}.tar.gz ]; then - tar -C staged -xzf staged/electron-app-${{ matrix.arch }}.tar.gz - elif [ ! -d staged/OpenChamber.app ] && [ -d staged/Contents ]; then - mkdir -p staged/OpenChamber.app - mv staged/Contents staged/OpenChamber.app/Contents - fi - - if [ ! -d staged/OpenChamber.app ]; then - echo "Error: staged/OpenChamber.app not found" - ls -la staged - exit 1 - fi - - APP_EXECUTABLE=$(find staged/OpenChamber.app/Contents/MacOS -type f -maxdepth 1 -print -quit) - if [ -z "$APP_EXECUTABLE" ] || [ ! -x "$APP_EXECUTABLE" ]; then - echo "Error: Electron app executable is missing or not executable" - ls -la staged/OpenChamber.app/Contents/MacOS - exit 1 - fi - - cd staged - TARBALL="OpenChamber.app.tar.gz" - tar -czf "$TARBALL" OpenChamber.app - - bun run --cwd ../packages/desktop tauri signer sign "$PWD/$TARBALL" - - mv "$TARBALL" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz" - mv "${TARBALL}.sig" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig" - - - name: Generate Tauri latest platform manifest - env: - VERSION: ${{ needs.create-release.outputs.version }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - SIG=$(cat staged/OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig) - TAR="OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz" - jq -n \ - --arg version "$VERSION" \ - --arg notes "OpenChamber has moved to Electron. This update replaces the Tauri shell with the Electron build. Subsequent updates will be delivered via the Electron auto-updater." \ - --arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --arg platform "${{ matrix.platform }}" \ - --arg signature "$SIG" \ - --arg url "https://github.com/${REPO}/releases/download/v${VERSION}/${TAR}" \ - '{ version: $version, notes: $notes, pub_date: $pub_date, platforms: { ($platform): { signature: $signature, url: $url } } }' \ - > staged/latest-${{ matrix.platform }}.json - - - name: Upload tarball and signature to release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ needs.create-release.outputs.version }} - files: | - staged/*.app.tar.gz - staged/*.app.tar.gz.sig - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload per-platform Tauri manifest for merge - uses: actions/upload-artifact@v7 - with: - name: tauri-manifest-${{ matrix.platform }} - path: staged/latest-${{ matrix.platform }}.json - retention-days: 1 - - combine-manifests: - needs: [create-release, repackage-electron-as-tauri-update] - runs-on: ubuntu-latest - steps: - - name: Download Tauri updater manifests - uses: actions/download-artifact@v4 - with: - pattern: tauri-manifest-* - path: artifacts - merge-multiple: true - - - name: Combine manifests - run: | - set -euo pipefail - - VERSION="${{ needs.create-release.outputs.version }}" - PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - if [ ! -f artifacts/latest-darwin-aarch64.json ]; then - echo "Error: aarch64 manifest not found" - exit 1 - fi - - if [ ! -f artifacts/latest-darwin-x86_64.json ]; then - echo "Error: x86_64 manifest not found" - exit 1 - fi - - if ! jq empty artifacts/latest-darwin-aarch64.json 2>/dev/null; then - echo "Error: aarch64 manifest is not valid JSON" - exit 1 - fi - - if ! jq empty artifacts/latest-darwin-x86_64.json 2>/dev/null; then - echo "Error: x86_64 manifest is not valid JSON" - exit 1 - fi - - if ! jq -e '.platforms["darwin-aarch64"]' artifacts/latest-darwin-aarch64.json > /dev/null; then - echo "Error: darwin-aarch64 platform data not found in manifest" - exit 1 - fi - - if ! jq -e '.platforms["darwin-x86_64"]' artifacts/latest-darwin-x86_64.json > /dev/null; then - echo "Error: darwin-x86_64 platform data not found in manifest" - exit 1 - fi - - jq -n \ - --arg version "$VERSION" \ - --arg notes "OpenChamber has moved to Electron. This update replaces the Tauri shell with the Electron build. Subsequent updates will be delivered via the Electron auto-updater." \ - --arg pub_date "$PUB_DATE" \ - --slurpfile aarch64 artifacts/latest-darwin-aarch64.json \ - --slurpfile x86_64 artifacts/latest-darwin-x86_64.json \ - '{ - version: $version, - notes: $notes, - pub_date: $pub_date, - platforms: { - "darwin-aarch64": $aarch64[0].platforms["darwin-aarch64"], - "darwin-x86_64": $x86_64[0].platforms["darwin-x86_64"] - } - }' > artifacts/latest.json - - cat artifacts/latest.json - - - name: Upload combined Tauri updater manifest - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ needs.create-release.outputs.version }} - files: artifacts/latest.json - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - combine-electron-manifests: needs: [create-release, build-desktop-electron-macos] runs-on: ubuntu-latest @@ -545,7 +346,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} finalize-release: - needs: [create-release, build-desktop-electron-macos, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests] + needs: [create-release, build-desktop-electron-macos, publish-npm, combine-electron-manifests] runs-on: ubuntu-latest env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index 9690cdea..d7d11811 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -49,7 +49,7 @@ Only use labels that already exist in this repository. Do not create labels. | `area:sync` | State sync, cross-runtime consistency | | `area:auth` | Authentication, passwords, OAuth, tunnels | | `area:installation` | Install, Docker, Nix, deployment | -| `area:desktop` | Desktop shell (Electron/Tauri), window management | +| `area:desktop` | Desktop shell (Electron), window management | | `area:keyboard` | Keyboard shortcuts, keybinds, input handling | | `area:permissions` | Permission prompts, allow/deny flows | | `area:compact` | Context compaction, /compact command | @@ -63,7 +63,7 @@ Only use labels that already exist in this repository. Do not create labels. | Label | Covers | |---|---| | `platform:web` | Desktop web browser (incl. CLI serve) | -| `platform:macos` | macOS desktop (Electron/Tauri) | +| `platform:macos` | macOS desktop (Electron) | | `platform:linux` | Linux desktop | | `platform:windows` | Windows desktop / WSL | | `platform:mobile` | Mobile web/PWA (iOS/Android) | @@ -105,4 +105,3 @@ For each issue: - Add a small set of accurate existing labels following the steps above. - In a single comment summarize the issue and ask the reporter for any additional information needed to complete the request. - Keep the comment friendly and concise. - diff --git a/AGENTS.md b/AGENTS.md index cb4c5747..e19d8ecb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,18 +7,15 @@ OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an O ## Runtime architecture (IMPORTANT) - `Desktop` (Electron) boots the web server **in the same Node process** as the Electron main, then loads the web UI from `http://127.0.0.1:`. No sidecar subprocess. -- `Desktop` (Tauri, legacy) still spawns `openchamber-server` as a bun-compiled sidecar binary. Kept only for auto-update compatibility with existing Tauri installs. - Backend/domain logic lives in `packages/web/server/*` (and `packages/vscode/*` for VS Code bridge/runtime parity). Electron owns the desktop shell/security boundary: windows, menus, dialogs, notifications, updater, deep-links, runtime host switching, local IPC gates, and SSH/tunnel management. - Do not add OpenCode feature backends to the native shell. Shared UI features should remain server/runtime APIs unless the capability is inherently native. -### Desktop shell: Electron is the target, Tauri is legacy +### Desktop Shell -- **New desktop work goes into `packages/electron/`.** This is the forward path. -- `packages/desktop/` (Tauri) is kept running in parallel only to preserve auto-update for existing installs until the cutover. Do **not** add features to it; do **not** port bug fixes back unless they actually affect currently-released Tauri users. -- Desktop-side changes (IPC handlers, native integrations, window/quit/notification behavior) land in `packages/electron/main.mjs` + `packages/electron/preload.mjs`. The `__TAURI__` shim exposed by the preload keeps the shared UI working against both shells, so renderer-side code should not branch on shell type. +- **Desktop work goes into `packages/electron/`.** +- Desktop-side changes (IPC handlers, native integrations, window/quit/notification behavior) land in `packages/electron/main.mjs` + `packages/electron/preload.mjs`. - Electron imports the server via `@openchamber/web/server/index.js` (workspace dep) and calls `startWebUiServer({...})`. The returned handle has `getPort()` / `stop()`. Notifications flow via an `onDesktopNotification` callback injected at startup — no stdout-parsing IPC. -- Build/release: Electron is the release target. The release workflow also repackages the signed Electron app as a Tauri updater payload for the one-shot migration path documented in `docs/TAURI_TO_ELECTRON_CUTOVER.md`. -- After the cutover ships and stabilises, `packages/desktop/` is deleted; this note collapses back to "Desktop is Electron". +- Build/release: Electron is the desktop release target. ## Tech stack (source of truth: `package.json`, resolved: `bun.lock`) @@ -27,8 +24,7 @@ OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an O - State: Zustand stores and sync layer (`packages/ui/src/stores/`, `packages/ui/src/sync/`) - UI primitives: Base UI (`@base-ui/react`, primary source for dropdown/select/dialog/menu/tooltip/etc. — wrappers live in `packages/ui/src/components/ui/`), Radix UI (`package.json` deps, legacy usages being migrated), HeroUI (`package.json` deps), Remixicon as SVG sprite source only (use shared `Icon`, never direct `@remixicon/react` imports) - Server: Express (`packages/web/server/index.js`) -- Desktop (forward): Electron 41 (`packages/electron/`) -- Desktop (legacy, maintenance-only): Tauri v2 (`packages/desktop/src-tauri/`) +- Desktop: Electron 41 (`packages/electron/`) - VS Code: extension + webview (`packages/vscode/`) ## Monorepo layout @@ -37,8 +33,7 @@ Workspaces are `packages/*` (see `package.json`). - Shared UI: `packages/ui` - Web app + server + CLI: `packages/web` -- Desktop shell (Electron — forward): `packages/electron` -- Desktop shell (Tauri — legacy, maintenance-only): `packages/desktop` +- Desktop shell: `packages/electron` - VS Code extension: `packages/vscode` ## Documentation map @@ -173,7 +168,6 @@ All scripts are in `package.json`. - Build all: `bun run build` - Desktop build (Electron — primary): `bun run electron:build` - Desktop dev (Electron): `bun run electron:dev` -- Desktop build (Tauri — legacy): `bun run desktop:build` - VS Code build: `bun run vscode:build` - Release smoke build: `bun run release:test` (shell script: `scripts/test-release-build.sh`) @@ -182,8 +176,7 @@ All scripts are in `package.json`. - Web bootstrap: `packages/web/src/main.tsx` - Web server: `packages/web/server/index.js` - Web CLI: `packages/web/bin/cli.js` (package bin: `packages/web/package.json`) -- Desktop (Electron — primary): `packages/electron/main.mjs` (boots the web server in-process via `startWebUiServer`, loads web UI over loopback; preload at `packages/electron/preload.mjs` exposes the `__TAURI__` IPC shim so shared UI code is shell-agnostic) -- Desktop (Tauri — legacy): `packages/desktop/src-tauri/src/main.rs` +- Desktop: `packages/electron/main.mjs` (boots the web server in-process via `startWebUiServer`, loads web UI over loopback; preload at `packages/electron/preload.mjs` exposes the desktop IPC bridge) - VS Code extension host: `packages/vscode/src/extension.ts` - VS Code webview bootstrap: `packages/vscode/webview/main.tsx` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95b0b7fa..7777e41d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,13 +19,13 @@ bun install Both are configurable via env vars: `OPENCHAMBER_PORT`, `OPENCHAMBER_HMR_UI_PORT`, `OPENCHAMBER_HMR_API_PORT`. -### Desktop (Tauri) +### Desktop (Electron) ```bash -bun run desktop:dev +bun run electron:dev ``` -Launches Tauri in dev mode with WebView devtools enabled and a distinct dev icon. +Launches the Electron desktop shell in dev mode. ### VS Code Extension @@ -72,7 +72,7 @@ bun run build # Must succeed packages/ ui/ Shared React components, hooks, stores, and theme system web/ Web server (Express) + frontend (Vite) + CLI - desktop/ Tauri macOS app (thin shell around the web UI) + electron/ Electron desktop shell vscode/ VS Code extension (extension host + webview) ``` diff --git a/README.md b/README.md index bc2889ce..36a1c1d2 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,6 @@ Independent project, not affiliated with the OpenCode team. - [OpenCode](https://opencode.ai) - For the excellent API and extensible architecture. - [Flexoki](https://github.com/kepano/flexoki) - Beautiful color scheme by [Steph Ango](https://stephango.com/flexoki). - [Pierre](https://pierrejs-docs.vercel.app/) - Fast, beautiful diff viewer with syntax highlighting. -- [Tauri](https://github.com/tauri-apps/tauri) - Desktop application framework. - [Ghostty-web](https://github.com/coder/ghostty-web) - Great implementation of a Ghostty web renderer. - [David Hill](https://x.com/iamdavidhill) - Who inspired me to release this without [overthinking](https://x.com/iamdavidhill/status/1993648326450020746). - [My wife](https://github.com/yulia-ivashko), who - with zero AI background - sat down with the app for the first time and built the firework celebration that plays on every successful push. diff --git a/bun.lock b/bun.lock index e962c162..9ba24423 100644 --- a/bun.lock +++ b/bun.lock @@ -96,18 +96,9 @@ "vite": "^7.1.2", }, }, - "packages/desktop": { - "name": "@openchamber/desktop", - "version": "1.11.7", - "devDependencies": { - "@tauri-apps/cli": "^2", - "@types/node": "^24.3.1", - "typescript": "~5.8.3", - }, - }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.11.7", + "version": "1.12.0", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -122,7 +113,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.11.7", + "version": "1.12.0", "dependencies": { "@base-ui/react": "^1.4.0", "@codemirror/autocomplete": "^6.20.0", @@ -197,7 +188,6 @@ "@eslint/js": "^9.33.0", "@remixicon/react": "^4.7.0", "@tailwindcss/postcss": "^4.0.0", - "@tauri-apps/api": "^2.10.1", "@types/node": "^24.3.1", "@types/prismjs": "^1.26.6", "@types/qrcode": "^1.5.5", @@ -223,7 +213,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.11.7", + "version": "1.12.0", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.15.10", @@ -246,7 +236,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.11.7", + "version": "1.12.0", "bin": { "openchamber": "./bin/cli.js", }, @@ -942,8 +932,6 @@ "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], - "@openchamber/desktop": ["@openchamber/desktop@workspace:packages/desktop"], - "@openchamber/electron": ["@openchamber/electron@workspace:packages/electron"], "@openchamber/ui": ["@openchamber/ui@workspace:packages/ui"], @@ -1254,32 +1242,6 @@ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.19", "", {}, "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g=="], - "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], - - "@tauri-apps/cli": ["@tauri-apps/cli@2.10.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.0", "@tauri-apps/cli-darwin-x64": "2.10.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", "@tauri-apps/cli-linux-arm64-musl": "2.10.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-musl": "2.10.0", "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", "@tauri-apps/cli-win32-x64-msvc": "2.10.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q=="], - - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA=="], - - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q=="], - - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.0", "", { "os": "linux", "cpu": "arm" }, "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g=="], - - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA=="], - - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA=="], - - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.0", "", { "os": "linux", "cpu": "none" }, "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw=="], - - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng=="], - - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q=="], - - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g=="], - - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A=="], - - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.0", "", { "os": "win32", "cpu": "x64" }, "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ=="], - "@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="], "@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="], diff --git a/docs/TAURI_TO_ELECTRON_CUTOVER.md b/docs/TAURI_TO_ELECTRON_CUTOVER.md deleted file mode 100644 index ec5b596d..00000000 --- a/docs/TAURI_TO_ELECTRON_CUTOVER.md +++ /dev/null @@ -1,414 +0,0 @@ -# Tauri → Electron auto-update cutover - -> Self-contained playbook. The branch and conversation where this plan was -> designed will not be around when the cutover happens — read this file top to -> bottom and execute; do not assume prior context. - -> Current status: the release workflow cutover is implemented. Desktop releases -> now build Electron and repackage that Electron `.app` into the old Tauri -> updater format for existing Tauri installs. The next safe engineering step is -> [Step 5 — Remove Tauri-specific code](#step-5--remove-tauri-specific-code), -> but only after the transition release has shipped and lived for at least 2 -> weeks with no rollback. - -## What this is - -OpenChamber historically shipped as a Tauri app. A parallel Electron shell was -added on branch `electron-app` (merged to `main` as part of a larger migration). -Since then, both desktop shells have been released in the same GitHub release -and each has its own auto-update channel: - -| Shell | Manifest | Update format | Secret used to sign | -|----------|-------------------|---------------------|---------------------| -| Tauri | `latest.json` | `.tar.gz` + `.sig` | `TAURI_SIGNING_PRIVATE_KEY` (Tauri signer / minisign format) | -| Electron | `latest-mac.yml` | `.zip` + `blockmap` | Developer ID codesign (APPLE_* secrets) | - -Existing Tauri installs keep their own auto-update path (`latest.json`). -Electron installs auto-update through `latest-mac.yml`. They coexist without -conflict because filenames and manifests differ. - -At some point the user wants to **stop maintaining the Tauri build** and make -the Tauri installs migrate themselves into Electron via auto-update. This -document describes how to do that in a single "transition release". - -## The core trick - -Tauri's updater downloads whatever `.tar.gz` the `latest.json` points at, -verifies the minisign signature, unpacks the contents **over** the existing -`.app` directory, and restarts. It does **not** introspect the payload — it -just replaces files. - -So: produce a `.tar.gz` of the Electron `.app`, sign it with the existing -Tauri minisign key, point `latest.json` at it. Tauri users receive the update, -their `OpenChamber.app` becomes the Electron bundle in-place, and next launch -starts Electron. Subsequent updates go through `latest-mac.yml` -(electron-updater). One-way migration, one-shot workflow change. - -## Prerequisites before running the cutover - -Check all of these before making any release: - -1. **Electron has shipped stable through its own `latest-mac.yml` path for at - least 2 releases.** Verify: - ``` - gh release list --repo btriapitsyn/openchamber - gh release view vX.Y.Z --repo btriapitsyn/openchamber \ - | grep -E 'OpenChamber-.*\.zip|latest-mac\.yml' - ``` - A user on Electron should have successfully auto-updated at least once. - If not, pause and stabilise that path first — don't stack risk. - -2. **`~/.config/openchamber/settings.json` is still the shared state path.** - Tauri `src-tauri/src/main.rs:settings_file_path` and Electron - `packages/electron/main.mjs:settingsFilePath` must both resolve to - `$HOME/.config/openchamber/settings.json`. If either has moved, data parity - breaks and this migration loses user data. Audit both paths, update the - non-migrated shell to match before proceeding. - -3. **Electron `appId` is `dev.openchamber.desktop`** (check - `packages/electron/package.json` `build.appId`). Tauri identifier is - `ai.opencode.openchamber`. These differ intentionally — it means macOS - LaunchServices will re-register after the in-place replace. That's fine but - see "Risks" below. - -4. **All GitHub secrets still valid:** `APPLE_CERTIFICATE`, - `APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`, - `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. A - workflow_dispatch dry-run should succeed before the real tag. - -5. **`minisign` CLI is available on the macOS runner** (or installable via - brew). Used to sign the Electron tarball with the Tauri key. - -## Current release workflow - -The release workflow no longer builds a Tauri desktop app. It now does this: - -```text -create-release -├── build-desktop-electron-macos (Electron .dmg/.zip/blockmap/latest-mac.yml) -├── repackage-electron-as-tauri-update (Electron .app -> Tauri .app.tar.gz/.sig) -├── publish-npm -├── combine-manifests (Tauri latest.json for migration only) -├── combine-electron-manifests (Electron latest-mac.yml) -└── finalize-release -``` - -The transition works like this: - -1. `build-desktop-electron-macos` builds, signs, and notarizes the Electron app. -2. It wraps the signed `OpenChamber.app` in a tarball and uploads that tarball - as a short-lived Actions artifact. -3. `repackage-electron-as-tauri-update` downloads that Electron `.app`. -4. It packs it into `OpenChamber--darwin-*.app.tar.gz`. -5. It signs that tarball with `tauri signer sign` and the existing Tauri signing key. -6. It uploads the tarball and `.sig` to the GitHub release. -7. It generates Tauri-compatible manifests and `combine-manifests` merges them into `latest.json`. - -So old Tauri installs still see the update contract they expect: - -```text -latest.json -> .app.tar.gz -> .sig -``` - -But the payload inside the `.app.tar.gz` is Electron, not Tauri. Tauri's updater -only verifies the signature and extracts the bundle over the existing -`/Applications/OpenChamber.app`. After restart, the app is Electron and future -updates use `latest-mac.yml` through `electron-updater`. - -Note: do not upload the `.app` directory directly with `actions/upload-artifact`. -That action can flatten the app to its inner `Contents/` folder and can also -normalize file modes. Losing the executable bit on `Contents/MacOS/*` makes the -updated app fail to launch with a permissions/package error. The workflow wraps -the `.app` in a tarball before upload so permissions survive the handoff between -jobs, then verifies the app executable is still executable before creating the -Tauri updater tarball. - -## Historical release workflow changes - -The file edited for the cutover was `.github/workflows/release.yml`. - -Before the cutover it had these jobs (simplified): - -``` -create-release -├── build-desktop-macos (Tauri .dmg/.tar.gz/.tar.gz.sig) -├── build-desktop-electron-macos (Electron .dmg/.zip/blockmap/latest-mac.yml) -├── publish-npm -├── combine-manifests (merges Tauri per-arch JSONs → latest.json) -├── combine-electron-manifests (merges Electron per-arch YMLs → latest-mac.yml) -└── finalize-release -``` - -### Step 1 — Remove the Tauri build - -Status: done. - -Delete these jobs entirely: -- `build-desktop-macos` -- `combine-manifests` - -They are replaced by the repackage job (below). `finalize-release` `needs:` -list must be updated to drop both. - -### Step 2 — Add a repackage job - -Status: done. - -Insert after `build-desktop-electron-macos`: - -```yaml -repackage-electron-as-tauri-update: - needs: [create-release, build-desktop-electron-macos] - runs-on: macos-26 - strategy: - fail-fast: false - matrix: - include: - - arch: arm64 - platform: darwin-aarch64 - - arch: x64 - platform: darwin-x86_64 - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - - uses: actions/setup-node@v4 - with: - node-version: '20' - - # Pull the signed+notarized Electron .app that build-desktop-electron-macos - # already produced. Either re-download the dmg and mount+copy the .app, or - # (cleaner) modify build-desktop-electron-macos to upload the .app itself - # as an artifact so this job can download it. Prefer the latter — adds one - # `actions/upload-artifact@v4` step uploading `packages/electron/dist/mac-/OpenChamber.app`. - - - name: Download signed Electron .app - uses: actions/download-artifact@v4 - with: - name: electron-app-${{ matrix.arch }} - path: staged - - - name: Tar and sign Electron .app as Tauri update payload - env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - VERSION: ${{ needs.create-release.outputs.version }} - run: | - set -euo pipefail - cd staged - # The tarball name convention Tauri's updater expects. Must end in - # `.app.tar.gz`. Name stays stable — Tauri updater does not care about - # the inner .app name. - TARBALL="OpenChamber.app.tar.gz" - tar -czf "$TARBALL" OpenChamber.app - - # Use Tauri's signer instead of minisign directly. The CI secret is in - # the format consumed by TAURI_SIGNING_PRIVATE_KEY. - bun run --cwd ../packages/desktop tauri signer sign "$PWD/$TARBALL" - - # Rename per platform so the release has distinct names for arm64/x64. - mv "$TARBALL" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz" - mv "${TARBALL}.sig" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig" - - - name: Generate Tauri latest-.json - env: - VERSION: ${{ needs.create-release.outputs.version }} - REPO: ${{ github.repository }} - run: | - SIG=$(cat staged/OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig) - TAR=OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz - cat > staged/latest-${{ matrix.platform }}.json < OpenChamber Desktop - -[![GitHub stars](https://img.shields.io/github/stars/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/btriapitsyn/openchamber/stargazers) -[![GitHub release](https://img.shields.io/github/v/release/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/btriapitsyn/openchamber/releases/latest) -[![Discord](https://img.shields.io/badge/Discord-join.svg?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) - -A native macOS app for [OpenCode](https://opencode.ai). Feels like home - multiple windows, SSH remotes, project actions, and everything running locally. - -Full project overview, screenshots, and all features: [github.com/btriapitsyn/openchamber](https://github.com/btriapitsyn/openchamber) - -## Install - -Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). Available for macOS (Apple Silicon and Intel). - -> **Prerequisite:** [OpenCode CLI](https://opencode.ai) installed. - -## What makes the desktop app special - -- **Remote instances over SSH** - connect to remote OpenChamber servers with dedicated lifecycle and UX flows -- **Project Actions** - run dev servers, configure SSH port forwarding, open remote URLs locally -- **Multi-window** - work on several projects in parallel, each in its own window -- **"Open In" shortcuts** - open workspace in Finder, Terminal, or your editor of choice -- **Local + remote switching** - jump between local and remote OpenChamber instances -- **Native macOS integration** - menus, deep-links, auto-update, and polished window management - -Plus everything from the shared OpenChamber UI: branchable timeline, Git sidebar, terminal, voice mode, and more. - -## Features - -### Core UI - -- Branchable chat timeline with `/undo`, `/redo`, and one-click forks from earlier turns -- Smart tool UIs for diffs, file operations, permissions, and long-running task progress -- Multi-agent runs from one prompt with isolated worktrees for safe comparisons -- Git workflows in-app: identities, commits, PR creation, checks, and merge actions -- Context visibility tools (token/cost breakdowns, raw message inspection, and activity summaries) -- Integrated terminal with per-directory sessions and stable performance on heavy output - -### Desktop (macOS) - -- Native macOS menu integration with polished app actions and deep-link handling -- Multi-window support for parallel project/session workflows -- "Open In" shortcuts for Finder, Terminal, and your preferred editor -- Fast switching between local and remote instances -- Workspace-first startup flow with directory picker and steadier window restore behavior - -### Remote Tunnel (Desktop) - -- Configure in **Settings -> OpenChamber -> Remote Tunnel**. -- Supported Cloudflare modes: **Quick**, **Managed Remote**, **Managed Local**. -- One active tunnel per Desktop instance. Starting a different mode replaces the current tunnel. -- Replacing or stopping a tunnel revokes existing connect links and invalidates remote tunnel sessions. -- Connect links are one-time tokens; generate a new link for each new connection attempt. - -## Development - -```bash -git clone https://github.com/btriapitsyn/openchamber.git -cd openchamber -bun install -bun run desktop:dev -``` - - - -## License - -MIT diff --git a/packages/desktop/noop-dist/index.html b/packages/desktop/noop-dist/index.html deleted file mode 100644 index 9d4b4ba6..00000000 --- a/packages/desktop/noop-dist/index.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - OpenChamber - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/desktop/package.json b/packages/desktop/package.json deleted file mode 100644 index 7e22bb1e..00000000 --- a/packages/desktop/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@openchamber/desktop", - "version": "1.12.0", - "private": true, - "type": "module", - "desktopPrerequisites": [ - "Rust stable toolchain (via rustup)", - "Xcode Command Line Tools installed", - "Tauri CLI installed (cargo install tauri-cli@^2)" - ], - "scripts": { - "tauri": "tauri", - "tauri:dev": "tauri dev --features devtools", - "tauri:build": "tauri build", - "build:sidecar": "node ./scripts/build-sidecar.mjs", - "build": "bun -e \"process.exit(0)\"", - "type-check": "bun -e \"process.exit(0)\"", - "lint": "bun -e \"process.exit(0)\"" - }, - "dependencies": {}, - "devDependencies": { - "@tauri-apps/cli": "^2", - "@types/node": "^24.3.1", - "typescript": "~5.8.3" - } -} diff --git a/packages/desktop/public/ibm-plex-mono-latin-600-normal.woff2 b/packages/desktop/public/ibm-plex-mono-latin-600-normal.woff2 deleted file mode 100644 index 67aeeb01..00000000 Binary files a/packages/desktop/public/ibm-plex-mono-latin-600-normal.woff2 and /dev/null differ diff --git a/packages/desktop/public/ibm-plex-mono-latin-700-normal.woff2 b/packages/desktop/public/ibm-plex-mono-latin-700-normal.woff2 deleted file mode 100644 index 5d7ff0c5..00000000 Binary files a/packages/desktop/public/ibm-plex-mono-latin-700-normal.woff2 and /dev/null differ diff --git a/packages/desktop/scripts/build-sidecar.mjs b/packages/desktop/scripts/build-sidecar.mjs deleted file mode 100644 index 11695fed..00000000 --- a/packages/desktop/scripts/build-sidecar.mjs +++ /dev/null @@ -1,135 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const webDir = path.join(repoRoot, 'packages', 'web'); -const desktopTauriDir = path.join(repoRoot, 'packages', 'desktop', 'src-tauri'); - -const resourcesDir = path.join(desktopTauriDir, 'resources'); -const resourcesWebDistDir = path.join(resourcesDir, 'web-dist'); -const webDistDir = path.join(webDir, 'dist'); - -const sidecarsDir = path.join(desktopTauriDir, 'sidecars'); - -const inferTargetTriple = () => { - if (typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' && process.env.TAURI_ENV_TARGET_TRIPLE.trim()) { - return process.env.TAURI_ENV_TARGET_TRIPLE.trim(); - } - - if (process.platform === 'darwin') { - return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'; - } - - if (process.platform === 'win32') { - return 'x86_64-pc-windows-msvc'; - } - - if (process.platform === 'linux') { - return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu'; - } - - return `${process.arch}-${process.platform}`; -}; - -const targetTriple = inferTargetTriple(); - -const bunCompileTargetByTriple = { - 'aarch64-apple-darwin': 'bun-darwin-arm64', - 'x86_64-apple-darwin': 'bun-darwin-x64', - 'aarch64-unknown-linux-gnu': 'bun-linux-arm64', - 'x86_64-unknown-linux-gnu': 'bun-linux-x64', - 'x86_64-pc-windows-msvc': 'bun-windows-x64', -}; - -const compileTarget = bunCompileTargetByTriple[targetTriple]; - -if (!compileTarget) { - console.warn( - `[desktop] unknown target triple '${targetTriple}', falling back to host-arch sidecar build` - ); -} - -const sidecarBaseName = process.platform === 'win32' - ? `openchamber-server-${targetTriple}.exe` - : `openchamber-server-${targetTriple}`; -const sidecarOutPath = path.join(sidecarsDir, sidecarBaseName); - - -const run = (cmd, args, cwd) => { - const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(`Command failed: ${cmd} ${args.join(' ')}`); - } -}; - -const resolveBun = () => { - if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) { - return process.env.BUN.trim(); - } - - const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' }); - const resolved = (result.stdout || '').trim(); - if (resolved) { - return resolved; - } - - return 'bun'; -}; - -const bunExe = resolveBun(); - - -const copyDir = async (src, dst) => { - await fs.mkdir(dst, { recursive: true }); - const entries = await fs.readdir(src, { withFileTypes: true }); - for (const entry of entries) { - const from = path.join(src, entry.name); - const to = path.join(dst, entry.name); - if (entry.isDirectory()) { - await copyDir(from, to); - } else if (entry.isSymbolicLink()) { - const link = await fs.readlink(from); - await fs.symlink(link, to); - } else { - await fs.copyFile(from, to); - } - } -}; - -console.log('[desktop] building web UI dist...'); -run(bunExe, ['run', 'build'], webDir); - -console.log('[desktop] preparing tauri resources...'); -await fs.mkdir(resourcesDir, { recursive: true }); -await fs.rm(resourcesWebDistDir, { recursive: true, force: true }); -await copyDir(webDistDir, resourcesWebDistDir); - -console.log('[desktop] building openchamber-server sidecar...'); -await fs.mkdir(sidecarsDir, { recursive: true }); - -const buildArgs = [ - 'build', - '--compile', - path.join(webDir, 'server', 'index.js'), - '--outfile', - sidecarOutPath, -]; - -if (compileTarget) { - buildArgs.push('--target', compileTarget); -} - -run(bunExe, buildArgs, repoRoot); - -if (process.platform !== 'win32') { - await fs.chmod(sidecarOutPath, 0o755); -} - -console.log(`[desktop] sidecar ready: ${sidecarOutPath}`); -console.log(`[desktop] web assets ready: ${resourcesWebDistDir}`); diff --git a/packages/desktop/scripts/desktop-dev.mjs b/packages/desktop/scripts/desktop-dev.mjs deleted file mode 100644 index c80ed907..00000000 --- a/packages/desktop/scripts/desktop-dev.mjs +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env node -import { spawn } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '../../..'); -const desktopDir = path.join(repoRoot, 'packages/desktop'); - -function spawnProcess(command, args, opts = {}) { - return spawn(command, args, { - cwd: repoRoot, - env: { ...process.env }, - stdio: 'inherit', - detached: process.platform !== 'win32', - ...opts, - }); -} - -function waitForExit(child, timeoutMs) { - return new Promise((resolve) => { - if (!child || child.exitCode !== null || child.signalCode !== null) { - resolve(); - return; - } - - const onExit = () => { - clearTimeout(timer); - resolve(); - }; - - const timer = setTimeout(() => { - child.off('exit', onExit); - resolve(); - }, timeoutMs); - - child.once('exit', onExit); - }); -} - -function signalChild(child, signal) { - if (!child || child.exitCode !== null || child.signalCode !== null) { - return; - } - - try { - if (process.platform !== 'win32') { - process.kill(-child.pid, signal); - return; - } - } catch { - } - - try { - child.kill(signal); - } catch { - } -} - -async function stopChildTree(child) { - if (!child || child.exitCode !== null || child.signalCode !== null) { - return; - } - - signalChild(child, 'SIGINT'); - await waitForExit(child, 2500); - - if (child.exitCode === null && child.signalCode === null) { - signalChild(child, 'SIGTERM'); - await waitForExit(child, 2500); - } - - if (child.exitCode === null && child.signalCode === null) { - signalChild(child, 'SIGKILL'); - await waitForExit(child, 1000); - } -} - -async function main() { - const tauriProcess = spawnProcess('bun', [ - '--cwd', - desktopDir, - 'tauri', - 'dev', - '--features', - 'devtools', - '--config', - './src-tauri/tauri.dev.conf.json', - ]); - - let cleaning = false; - - const teardown = async (code) => { - if (cleaning) { - return; - } - cleaning = true; - - await stopChildTree(tauriProcess); - process.exit(typeof code === 'number' ? code : 0); - }; - - const handleChildExit = (childName) => (code, signal) => { - if (code !== 0 || signal) { - console.warn(`[desktop:dev] ${childName} exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`); - } - teardown(code).catch((error) => { - console.error('[desktop:dev] Cleanup error:', error); - process.exit(code ?? 1); - }); - }; - - tauriProcess.on('exit', handleChildExit('Tauri dev process')); - const errorHandler = (label) => (error) => { - console.error(`[desktop:dev] Failed to start ${label}:`, error); - teardown(1).catch(() => process.exit(1)); - }; - - tauriProcess.on('error', errorHandler('Tauri dev process')); - - const signalExitCodes = { - SIGINT: 130, - SIGTERM: 143, - SIGQUIT: 131, - }; - - Object.entries(signalExitCodes).forEach(([signal, exitCode]) => { - process.on(signal, () => { - teardown(exitCode).catch(() => process.exit(exitCode)); - }); - }); -} - -main().catch((error) => { - console.error('[desktop:dev] Unexpected error:', error); - process.exit(1); -}); diff --git a/packages/desktop/scripts/dev-web-server.mjs b/packages/desktop/scripts/dev-web-server.mjs deleted file mode 100644 index 0af68b5e..00000000 --- a/packages/desktop/scripts/dev-web-server.mjs +++ /dev/null @@ -1,197 +0,0 @@ -import path from 'node:path'; -import { spawn, spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const DESKTOP_DEV_PORT = 3901; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const desktopDir = path.join(repoRoot, 'packages', 'desktop'); -const tauriDir = path.join(desktopDir, 'src-tauri'); - -const inferTargetTriple = () => { - const fromEnv = typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' ? process.env.TAURI_ENV_TARGET_TRIPLE.trim() : ''; - if (fromEnv) return fromEnv; - - if (process.platform === 'darwin') { - return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'; - } - - if (process.platform === 'win32') { - return 'x86_64-pc-windows-msvc'; - } - - if (process.platform === 'linux') { - return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu'; - } - - return `${process.arch}-${process.platform}`; -}; - -const targetTriple = inferTargetTriple(); -const sidecarName = process.platform === 'win32' - ? `openchamber-server-${targetTriple}.exe` - : `openchamber-server-${targetTriple}`; - -const sidecarPath = path.join(tauriDir, 'sidecars', sidecarName); -const distDir = path.join(tauriDir, 'resources', 'web-dist'); -const webDir = path.join(repoRoot, 'packages', 'web'); - -const run = (cmd, args, cwd) => { - const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(`Command failed: ${cmd} ${args.join(' ')}`); - } -}; - -console.log('[desktop] ensuring sidecar + web-dist...'); -run('node', ['./scripts/build-sidecar.mjs'], desktopDir); - -console.log(`[desktop] starting API server on http://127.0.0.1:${DESKTOP_DEV_PORT} ...`); - -const apiChild = spawn(sidecarPath, ['--port', String(DESKTOP_DEV_PORT)], { - cwd: repoRoot, - stdio: 'inherit', - detached: process.platform !== 'win32', - env: { - ...process.env, - OPENCHAMBER_HOST: '127.0.0.1', - OPENCHAMBER_DIST_DIR: distDir, - NO_PROXY: process.env.NO_PROXY || 'localhost,127.0.0.1', - no_proxy: process.env.no_proxy || 'localhost,127.0.0.1', - }, -}); - -console.log('[desktop] starting Vite HMR server on http://127.0.0.1:5173 ...'); - -const webChild = spawn('bun', ['x', 'vite', '--host', '127.0.0.1', '--port', '5173', '--strictPort'], { - cwd: webDir, - stdio: 'inherit', - detached: process.platform !== 'win32', - env: { - ...process.env, - OPENCHAMBER_PORT: String(DESKTOP_DEV_PORT), - NO_PROXY: process.env.NO_PROXY || 'localhost,127.0.0.1', - no_proxy: process.env.no_proxy || 'localhost,127.0.0.1', - }, -}); - -let shuttingDown = false; - -function waitForExit(child, timeoutMs) { - return new Promise((resolve) => { - if (!child || child.exitCode !== null || child.signalCode !== null) { - resolve(); - return; - } - - const onExit = () => { - clearTimeout(timer); - resolve(); - }; - - const timer = setTimeout(() => { - child.off('exit', onExit); - resolve(); - }, timeoutMs); - - child.once('exit', onExit); - }); -} - -function signalChild(child, signal) { - if (!child || child.exitCode !== null || child.signalCode !== null) { - return; - } - - try { - if (process.platform !== 'win32') { - process.kill(-child.pid, signal); - return; - } - } catch { - } - - try { - child.kill(signal); - } catch { - } -} - -async function requestApiShutdown() { - const url = `http://127.0.0.1:${DESKTOP_DEV_PORT}/api/system/shutdown`; - try { - await fetch(url, { method: 'POST' }); - } catch { - } -} - -async function stopChildTree(child) { - if (!child || child.exitCode !== null || child.signalCode !== null) { - return; - } - - signalChild(child, 'SIGINT'); - await waitForExit(child, 2500); - - if (child.exitCode === null && child.signalCode === null) { - signalChild(child, 'SIGTERM'); - await waitForExit(child, 2500); - } - - if (child.exitCode === null && child.signalCode === null) { - signalChild(child, 'SIGKILL'); - await waitForExit(child, 1000); - } -} - -const shutdown = async (exitCode = 0) => { - if (shuttingDown) return; - shuttingDown = true; - - await requestApiShutdown(); - await Promise.all([stopChildTree(webChild), stopChildTree(apiChild)]); - process.exit(exitCode); -}; - -const handleExit = (label) => (code, signal) => { - if (shuttingDown) { - return; - } - - if (code !== 0 || signal) { - console.error(`[desktop] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`); - } - - shutdown(typeof code === 'number' ? code : 1).catch((error) => { - console.error('[desktop] shutdown failed:', error); - process.exit(1); - }); -}; - -apiChild.on('exit', handleExit('API server')); -webChild.on('exit', handleExit('Vite server')); - -const handleError = (label) => (error) => { - if (shuttingDown) { - return; - } - console.error(`[desktop] failed to start ${label}:`, error); - shutdown(1).catch(() => process.exit(1)); -}; - -apiChild.on('error', handleError('API server')); -webChild.on('error', handleError('Vite server')); - -process.on('SIGINT', () => { - shutdown(130).catch(() => process.exit(130)); -}); -process.on('SIGTERM', () => { - shutdown(143).catch(() => process.exit(143)); -}); -process.on('SIGHUP', () => { - shutdown(129).catch(() => process.exit(129)); -}); diff --git a/packages/desktop/scripts/opencode-cli.mjs b/packages/desktop/scripts/opencode-cli.mjs deleted file mode 100644 index 199fc967..00000000 --- a/packages/desktop/scripts/opencode-cli.mjs +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env node -import { spawn } from 'node:child_process'; -import { access, readFile, unlink, writeFile } from 'node:fs/promises'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const desktopDir = path.resolve(__dirname, '..'); -const stateFile = path.join(desktopDir, '.opencode-cli-state.json'); -const DEFAULT_BIN_CANDIDATES = [ - process.env.OPENCHAMBER_OPENCODE_PATH, - process.env.OPENCHAMBER_OPENCODE_BIN, - process.env.OPENCODE_PATH, - process.env.OPENCODE_BINARY, - '/opt/homebrew/bin/opencode', - '/usr/local/bin/opencode', - '/usr/bin/opencode', - path.join(os.homedir(), '.local/bin/opencode'), -].filter(Boolean); -const CLI_ARGS_ENV = process.env.OPENCHAMBER_OPENCODE_ARGS; -const DEFAULT_ARGS = CLI_ARGS_ENV - ? parseArgs(CLI_ARGS_ENV) - : ['api']; - -function parseArgs(raw) { - if (!raw || typeof raw !== 'string') { - return []; - } - const trimmed = raw.trim(); - if (!trimmed) { - return []; - } - if (trimmed.startsWith('[')) { - try { - const parsed = JSON.parse(trimmed); - if (Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')) { - return parsed; - } - } catch { - // fall through to whitespace split - } - } - return trimmed.split(/\s+/g); -} - -async function fileExists(targetPath) { - try { - await access(targetPath, fs.constants.X_OK); - return true; - } catch { - return false; - } -} - -async function resolveCliPath() { - for (const candidate of DEFAULT_BIN_CANDIDATES) { - if (candidate && await fileExists(candidate)) { - return candidate; - } - } - - const envPath = process.env.PATH || ''; - for (const segment of envPath.split(path.delimiter)) { - const candidate = path.join(segment, 'opencode'); - if (await fileExists(candidate)) { - return candidate; - } - } - - throw new Error('Unable to locate the OpenCode CLI. Set OPENCHAMBER_OPENCODE_PATH to the executable.'); -} - -async function readState() { - try { - const raw = await readFile(stateFile, 'utf8'); - const data = JSON.parse(raw); - if (typeof data?.pid === 'number') { - return data; - } - } catch { - // ignore - } - return null; -} - -function isProcessAlive(pid) { - if (!pid || typeof pid !== 'number') { - return false; - } - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function writeState(pid) { - await writeFile(stateFile, JSON.stringify({ pid }), 'utf8'); -} - -async function removeStateFile() { - try { - await unlink(stateFile); - } catch { - // already removed - } -} - -function spawnCli(cliPath, args) { - const env = { - ...process.env, - OPENCHAMBER_OPENCODE_PORT: process.env.OPENCHAMBER_OPENCODE_PORT || process.env.OPENCODE_PORT || process.env.OPENCHAMBER_INTERNAL_PORT || '0', - }; - const cwd = process.env.OPENCHAMBER_OPENCODE_CWD || process.cwd(); - - const child = spawn(cliPath, args.length > 0 ? args : DEFAULT_ARGS, { - cwd, - env, - detached: true, - stdio: 'ignore', - }); - - child.unref(); - return child; -} - -export async function startCli({ silent = false } = {}) { - const existing = await readState(); - if (existing?.pid && isProcessAlive(existing.pid)) { - if (!silent) { - console.log(`[desktop:start-cli] OpenCode CLI already running (pid ${existing.pid}).`); - } - return existing.pid; - } - - const cliPath = await resolveCliPath(); - const child = spawnCli(cliPath, DEFAULT_ARGS); - await writeState(child.pid); - if (!silent) { - console.log(`[desktop:start-cli] OpenCode CLI started (${cliPath}) pid ${child.pid}.`); - } - return child.pid; -} - -export async function stopCli({ silent = false } = {}) { - const state = await readState(); - if (!state?.pid) { - if (!silent) { - console.log('[desktop:stop-cli] No OpenCode CLI PID recorded.'); - } - return; - } - - const { pid } = state; - if (!isProcessAlive(pid)) { - await removeStateFile(); - if (!silent) { - console.log('[desktop:stop-cli] CLI already stopped.'); - } - return; - } - - try { - process.kill(pid, 'SIGTERM'); - } catch (error) { - if (!silent) { - console.error(`[desktop:stop-cli] Failed to send SIGTERM to pid ${pid}:`, error); - } - } - - const timeoutMs = 5000; - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (!isProcessAlive(pid)) { - await removeStateFile(); - if (!silent) { - console.log('[desktop:stop-cli] OpenCode CLI stopped.'); - } - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - try { - process.kill(pid, 'SIGKILL'); - if (!silent) { - console.warn(`[desktop:stop-cli] Forced termination sent to pid ${pid}.`); - } - } catch (error) { - if (!silent) { - console.error(`[desktop:stop-cli] Unable to terminate pid ${pid}:`, error); - } - } finally { - await removeStateFile(); - } -} - -async function main() { - const [, , command] = process.argv; - if (!command || command === '--help' || command === '-h') { - console.log('Usage: node opencode-cli.mjs '); - process.exit(0); - } - - if (command === 'start') { - await startCli(); - return; - } - if (command === 'stop') { - await stopCli(); - return; - } - if (command === 'status') { - const state = await readState(); - if (state?.pid && isProcessAlive(state.pid)) { - console.log(`OpenCode CLI running (pid ${state.pid}).`); - } else { - console.log('OpenCode CLI not running.'); - } - process.exit(0); - return; - } - - console.error(`Unknown command: ${command}`); - process.exit(1); -} - -if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { - main().catch((error) => { - console.error('[desktop:opencode-cli] Unexpected error:', error); - process.exit(1); - }); -} diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock deleted file mode 100644 index 5863a04d..00000000 --- a/packages/desktop/src-tauri/Cargo.lock +++ /dev/null @@ -1,6688 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "android_log-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" - -[[package]] -name = "android_logger" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" -dependencies = [ - "android_log-sys", - "env_filter", - "log", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "borsh" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" -dependencies = [ - "once_cell", - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "byte-unit" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" -dependencies = [ - "rust_decimal", - "schemars 1.2.1", - "serde", - "utf8-width", -] - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.11.0", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "cargo_toml" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" -dependencies = [ - "serde", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "cc" -version = "1.2.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfb" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] - -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link 0.2.1", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - -[[package]] -name = "cssparser" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf 0.13.1", - "smallvec", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "ctor" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.11.0", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "dlopen2_derive" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dom_query" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" -dependencies = [ - "bit-set", - "cssparser 0.36.0", - "foldhash 0.2.0", - "html5ever 0.38.0", - "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -dependencies = [ - "serde", -] - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "embed-resource" -version = "3.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 0.9.12+spec-1.1.0", - "vswhom", - "winreg", -] - -[[package]] -name = "embed_plist" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "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.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "fern" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" -dependencies = [ - "log", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkwayland-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" -dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.11.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - -[[package]] -name = "html5ever" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" -dependencies = [ - "log", - "markup5ever 0.38.0", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "ico" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" -dependencies = [ - "byteorder", - "png", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "infer" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "javascriptcore-rs" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" -dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", -] - -[[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "js-sys" -version = "0.3.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "json-patch" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" -dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "jsonptr" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.11.0", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.13.0", - "selectors 0.24.0", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading", - "once_cell", -] - -[[package]] -name = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" - -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[package]] -name = "libredox" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" -dependencies = [ - "bitflags 2.11.0", - "libc", - "plain", - "redox_syscall 0.7.3", -] - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -dependencies = [ - "value-bag", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "mac-notification-sys" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" -dependencies = [ - "cc", - "objc2", - "objc2-foundation", - "time", -] - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", -] - -[[package]] -name = "markup5ever" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" -dependencies = [ - "log", - "tendril 0.5.0", - "web_atoms", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minisign-verify" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.2", -] - -[[package]] -name = "muda" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.11.0", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "notify-rust" -version = "4.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" -dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", - "objc2-exception-helper", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.11.0", - "block2", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.0", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.11.0", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.11.0", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-osa-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-app-kit", - "objc2-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-web-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" -dependencies = [ - "bitflags 2.11.0", - "block2", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-javascript-core", - "objc2-security", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "dunce", - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "openchamber-desktop" -version = "1.12.0" -dependencies = [ - "anyhow", - "base64 0.22.1", - "log", - "objc2", - "objc2-web-kit", - "reqwest 0.12.28", - "rfd 0.15.4", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-log", - "tauri-plugin-notification", - "tauri-plugin-shell", - "tauri-plugin-updater", - "tokio", - "url", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "osakit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" -dependencies = [ - "objc2", - "objc2-foundation", - "objc2-osa-kit", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher 1.0.2", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "plist" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" -dependencies = [ - "base64 0.22.1", - "indexmap 2.13.0", - "quick-xml 0.38.4", - "serde", - "time", -] - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.8+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "reqwest" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "rfd" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" -dependencies = [ - "ashpd", - "block2", - "dispatch2", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "pollster", - "raw-window-handle", - "urlencoding", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rfd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" -dependencies = [ - "block2", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.60.2", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "rust_decimal" -version = "1.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.5", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "indexmap 1.9.3", - "schemars_derive", - "serde", - "serde_json", - "url", - "uuid", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - -[[package]] -name = "selectors" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" -dependencies = [ - "bitflags 2.11.0", - "cssparser 0.36.0", - "derive_more 2.1.1", - "log", - "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", - "precomputed-hash", - "rustc-hash", - "servo_arc 0.4.3", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.13.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serialize-to-javascript" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" -dependencies = [ - "serde", - "serde_json", - "serialize-to-javascript-impl", -] - -[[package]] -name = "serialize-to-javascript-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - -[[package]] -name = "servo_arc" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shared_child" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" -dependencies = [ - "libc", - "sigchld", - "windows-sys 0.60.2", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "sigchld" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" -dependencies = [ - "libc", - "os_pipe", - "signal-hook", -] - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "softbuffer" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" -dependencies = [ - "bytemuck", - "js-sys", - "ndk", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", - "raw-window-handle", - "redox_syscall 0.5.18", - "tracing", - "wasm-bindgen", - "web-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "soup3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.13.1", - "precomputed-hash", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - -[[package]] -name = "string_cache_codegen" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "swift-rs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" -dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.2", - "version-compare", -] - -[[package]] -name = "tao" -version = "0.34.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" -dependencies = [ - "bitflags 2.11.0", - "block2", - "core-foundation", - "core-graphics", - "crossbeam-channel", - "dispatch2", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "once_cell", - "parking_lot", - "raw-window-handle", - "tao-macros", - "unicode-segmentation", - "url", - "windows", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tar" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tauri" -version = "2.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" -dependencies = [ - "anyhow", - "bytes", - "cookie", - "dirs", - "dunce", - "embed_plist", - "getrandom 0.3.4", - "glob", - "gtk", - "heck 0.5.0", - "http", - "jni", - "libc", - "log", - "mime", - "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "percent-encoding", - "plist", - "raw-window-handle", - "reqwest 0.13.2", - "serde", - "serde_json", - "serde_repr", - "serialize-to-javascript", - "swift-rs", - "tauri-build", - "tauri-macros", - "tauri-runtime", - "tauri-runtime-wry", - "tauri-utils", - "thiserror 2.0.18", - "tokio", - "tray-icon", - "url", - "webkit2gtk", - "webview2-com", - "window-vibrancy", - "windows", -] - -[[package]] -name = "tauri-build" -version = "2.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" -dependencies = [ - "anyhow", - "cargo_toml", - "dirs", - "glob", - "heck 0.5.0", - "json-patch", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "tauri-utils", - "tauri-winres", - "toml 0.9.12+spec-1.1.0", - "walkdir", -] - -[[package]] -name = "tauri-codegen" -version = "2.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" -dependencies = [ - "base64 0.22.1", - "brotli", - "ico", - "json-patch", - "plist", - "png", - "proc-macro2", - "quote", - "semver", - "serde", - "serde_json", - "sha2", - "syn 2.0.117", - "tauri-utils", - "thiserror 2.0.18", - "time", - "url", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-macros" -version = "2.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "tauri-codegen", - "tauri-utils", -] - -[[package]] -name = "tauri-plugin" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" -dependencies = [ - "anyhow", - "glob", - "plist", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri-utils", - "toml 0.9.12+spec-1.1.0", - "walkdir", -] - -[[package]] -name = "tauri-plugin-dialog" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" -dependencies = [ - "log", - "raw-window-handle", - "rfd 0.16.0", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.18", - "url", -] - -[[package]] -name = "tauri-plugin-fs" -version = "2.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" -dependencies = [ - "anyhow", - "dunce", - "glob", - "percent-encoding", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", - "url", -] - -[[package]] -name = "tauri-plugin-log" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" -dependencies = [ - "android_logger", - "byte-unit", - "fern", - "log", - "objc2", - "objc2-foundation", - "serde", - "serde_json", - "serde_repr", - "swift-rs", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "tauri-plugin-notification" -version = "2.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" -dependencies = [ - "log", - "notify-rust", - "rand 0.9.2", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "time", - "url", -] - -[[package]] -name = "tauri-plugin-shell" -version = "2.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" -dependencies = [ - "encoding_rs", - "log", - "open", - "os_pipe", - "regex", - "schemars 0.8.22", - "serde", - "serde_json", - "shared_child", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "tauri-plugin-updater" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" -dependencies = [ - "base64 0.22.1", - "dirs", - "flate2", - "futures-util", - "http", - "infer", - "log", - "minisign-verify", - "osakit", - "percent-encoding", - "reqwest 0.13.2", - "rustls", - "semver", - "serde", - "serde_json", - "tar", - "tauri", - "tauri-plugin", - "tempfile", - "thiserror 2.0.18", - "time", - "tokio", - "url", - "windows-sys 0.60.2", - "zip", -] - -[[package]] -name = "tauri-runtime" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" -dependencies = [ - "cookie", - "dpi", - "gtk", - "http", - "jni", - "objc2", - "objc2-ui-kit", - "objc2-web-kit", - "raw-window-handle", - "serde", - "serde_json", - "tauri-utils", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webview2-com", - "windows", -] - -[[package]] -name = "tauri-runtime-wry" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" -dependencies = [ - "gtk", - "http", - "jni", - "log", - "objc2", - "objc2-app-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "softbuffer", - "tao", - "tauri-runtime", - "tauri-utils", - "url", - "webkit2gtk", - "webview2-com", - "windows", - "wry", -] - -[[package]] -name = "tauri-utils" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" -dependencies = [ - "anyhow", - "brotli", - "cargo_metadata", - "ctor", - "dunce", - "glob", - "html5ever 0.29.1", - "http", - "infer", - "json-patch", - "kuchikiki", - "log", - "memchr", - "phf 0.11.3", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "semver", - "serde", - "serde-untagged", - "serde_json", - "serde_with", - "swift-rs", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", - "url", - "urlpattern", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-winres" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" -dependencies = [ - "dunce", - "embed-resource", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "tauri-winrt-notification" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" -dependencies = [ - "quick-xml 0.37.5", - "thiserror 2.0.18", - "windows", - "windows-version", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "tendril" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" -dependencies = [ - "new_debug_unreachable", - "utf-8", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.13.0", - "serde_core", - "serde_spanned 1.1.0", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml_datetime" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.13.0", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" -dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.25.8+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" -dependencies = [ - "indexmap 2.13.0", - "toml_datetime 1.1.0+spec-1.1.0", - "toml_parser", - "winnow 1.0.0", -] - -[[package]] -name = "toml_parser" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" -dependencies = [ - "winnow 1.0.0", -] - -[[package]] -name = "toml_writer" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.11.0", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "tray-icon" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "urlpattern" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" -dependencies = [ - "regex", - "serde", - "unic-ucd-ident", - "url", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vswhom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" -dependencies = [ - "libc", - "vswhom-sys", -] - -[[package]] -name = "vswhom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.13.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", -] - -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags 2.11.0", - "rustix", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" -dependencies = [ - "bitflags 2.11.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" -dependencies = [ - "proc-macro2", - "quick-xml 0.39.2", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web_atoms" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" -dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", -] - -[[package]] -name = "webkit2gtk" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webview2-com" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "webview2-com-sys" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" -dependencies = [ - "thiserror 2.0.18", - "windows", - "windows-core 0.61.2", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "window-vibrancy" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" -dependencies = [ - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "windows-sys 0.59.0", - "windows-version", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.13.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wry" -version = "0.54.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" -dependencies = [ - "base64 0.22.1", - "block2", - "cookie", - "crossbeam-channel", - "dirs", - "dom_query", - "dpi", - "dunce", - "gdkx11", - "gtk", - "http", - "javascriptcore-rs", - "jni", - "libc", - "ndk", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2", - "soup3", - "tao-macros", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 0.7.15", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" -dependencies = [ - "serde", - "winnow 0.7.15", - "zvariant", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zip" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.13.0", - "memchr", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zvariant" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" -dependencies = [ - "endi", - "enumflags2", - "serde", - "url", - "winnow 0.7.15", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "winnow 0.7.15", -] diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml deleted file mode 100644 index 2ba5aa6f..00000000 --- a/packages/desktop/src-tauri/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "openchamber-desktop" -version = "1.12.0" -edition = "2021" -publish = false - -[[bin]] -name = "openchamber-desktop" -path = "src/main.rs" - -[features] -default = [] -devtools = ["tauri/devtools"] - -[dependencies] -anyhow = "1.0.86" -base64 = "0.22.1" -log = "0.4.28" -reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "json"] } -serde = { version = "1.0.210", features = ["derive"] } -serde_json = "1.0.143" -tauri = { version = "2.10.3", features = ["macos-private-api"] } -tauri-plugin-dialog = "2.6.0" -tauri-plugin-log = "2.8.0" -tauri-plugin-shell = "2.3.5" -tauri-plugin-notification = "2.3.3" -tauri-plugin-updater = "2.10.0" -tokio = { version = "1.38", features = ["rt-multi-thread", "time", "macros", "sync"] } -url = "2.5" - -[build-dependencies] -tauri-build = { version = "2.5.6", features = [] } - -[profile.release] -lto = "thin" -codegen-units = 1 -strip = true - -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = "0.6" -objc2-web-kit = "0.3" -rfd = "0.15" diff --git a/packages/desktop/src-tauri/Info.plist b/packages/desktop/src-tauri/Info.plist deleted file mode 100644 index 9225c2ca..00000000 --- a/packages/desktop/src-tauri/Info.plist +++ /dev/null @@ -1,27 +0,0 @@ - - - - - NSSupportsAutomaticTermination - - NSSupportsSuddenTermination - - NSAppleEventsUsageDescription - OpenChamber needs to run the OpenCode CLI to provide AI coding assistance. - NSDesktopFolderUsageDescription - OpenChamber needs access to work with your projects. - NSDocumentsFolderUsageDescription - OpenChamber needs access to work with your projects. - NSDownloadsFolderUsageDescription - OpenChamber needs access to work with your projects. - NSMicrophoneUsageDescription - OpenChamber needs microphone access for voice input. - NSSpeechRecognitionUsageDescription - OpenChamber needs speech recognition to transcribe voice input. - NSAppTransportSecurity - - NSAllowsArbitraryLoadsInWebContent - - - - diff --git a/packages/desktop/src-tauri/build.rs b/packages/desktop/src-tauri/build.rs deleted file mode 100644 index 261851f6..00000000 --- a/packages/desktop/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build(); -} diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json deleted file mode 100644 index 9e9a8841..00000000 --- a/packages/desktop/src-tauri/capabilities/default.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Default capabilities for OpenChamber desktop runtime", - "remote": { - "urls": [ - "http://127.0.0.1:*/*", - "http://localhost:*/*", - "http://*", - "http://*/*", - "https://*", - "https://*/*" - ] - }, - "windows": ["main", "main-*"], - "permissions": [ - "core:default", - "core:window:default", - "core:window:allow-close", - "core:window:allow-set-title", - "core:window:allow-set-size", - "core:window:allow-set-position", - "core:window:allow-start-dragging", - "core:webview:default", - "core:webview:allow-webview-close", - "shell:allow-open", - "shell:allow-execute", - "dialog:allow-open", - "dialog:allow-save", - "dialog:allow-message", - "dialog:allow-ask", - "dialog:allow-confirm", - "notification:default", - "notification:allow-is-permission-granted", - "notification:allow-request-permission", - "notification:allow-notify", - "updater:default", - "updater:allow-check", - "updater:allow-download-and-install" - ] -} diff --git a/packages/desktop/src-tauri/entitlements.plist b/packages/desktop/src-tauri/entitlements.plist deleted file mode 100644 index 79a8b23a..00000000 --- a/packages/desktop/src-tauri/entitlements.plist +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.disable-executable-page-protection - - com.apple.security.cs.disable-library-validation - - com.apple.security.device.audio-input - - - diff --git a/packages/desktop/src-tauri/icons/app-icon.png b/packages/desktop/src-tauri/icons/app-icon.png deleted file mode 100644 index c3f9a038..00000000 Binary files a/packages/desktop/src-tauri/icons/app-icon.png and /dev/null differ diff --git a/packages/desktop/src-tauri/icons/app-icon.svg b/packages/desktop/src-tauri/icons/app-icon.svg deleted file mode 100644 index f271cda8..00000000 --- a/packages/desktop/src-tauri/icons/app-icon.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/desktop/src-tauri/icons/dev-icon.icns b/packages/desktop/src-tauri/icons/dev-icon.icns deleted file mode 100644 index 04ac8d54..00000000 Binary files a/packages/desktop/src-tauri/icons/dev-icon.icns and /dev/null differ diff --git a/packages/desktop/src-tauri/icons/dev-icon.png b/packages/desktop/src-tauri/icons/dev-icon.png deleted file mode 100644 index bab0ca21..00000000 Binary files a/packages/desktop/src-tauri/icons/dev-icon.png and /dev/null differ diff --git a/packages/desktop/src-tauri/icons/icon.icns b/packages/desktop/src-tauri/icons/icon.icns deleted file mode 100644 index b6def080..00000000 Binary files a/packages/desktop/src-tauri/icons/icon.icns and /dev/null differ diff --git a/packages/desktop/src-tauri/icons/icon.png b/packages/desktop/src-tauri/icons/icon.png deleted file mode 100644 index b9c1331d..00000000 Binary files a/packages/desktop/src-tauri/icons/icon.png and /dev/null differ diff --git a/packages/desktop/src-tauri/resources/.gitkeep b/packages/desktop/src-tauri/resources/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/desktop/src-tauri/sidecars/.gitkeep b/packages/desktop/src-tauri/sidecars/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs deleted file mode 100644 index fe9ea696..00000000 --- a/packages/desktop/src-tauri/src/main.rs +++ /dev/null @@ -1,4714 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -mod remote_ssh; - -use anyhow::{anyhow, Result}; -use base64::{engine::general_purpose, Engine as _}; -use remote_ssh::DesktopSshManagerState; -use serde::{Deserialize, Serialize}; -use std::env; -use std::time::{SystemTime, UNIX_EPOCH}; -use std::{ - collections::{HashMap, HashSet}, - fs, - path::{Path, PathBuf}, -}; -use std::{ - net::{TcpListener, UdpSocket}, - process::Command, - sync::{ - atomic::{AtomicU32, AtomicU64, Ordering}, - Mutex, - }, - time::Duration, -}; -use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; - -/// Disable pinch-to-zoom / magnification gestures on macOS to avoid accidental -/// zoom and the continuous gesture event processing overhead. -#[cfg(target_os = "macos")] -fn disable_pinch_zoom(window: &tauri::WebviewWindow) { - let _ = window.with_webview(|webview| unsafe { - use objc2::rc::Retained; - use objc2_web_kit::WKWebView; - let wk_webview: Retained = - Retained::retain(webview.inner().cast()).unwrap(); - wk_webview.setAllowsMagnification(false); - }); -} - -#[cfg(not(target_os = "macos"))] -fn disable_pinch_zoom(_window: &tauri::WebviewWindow) {} - -/// Global counter for generating unique window labels. -static WINDOW_COUNTER: AtomicU64 = AtomicU64::new(1); - -fn next_window_label(app: &tauri::AppHandle) -> String { - loop { - let n = WINDOW_COUNTER.fetch_add(1, Ordering::Relaxed); - let candidate = if n == 1 { - "main".to_string() - } else { - format!("main-{n}") - }; - - if !app.webview_windows().contains_key(&candidate) { - return candidate; - } - } -} - -/// Evaluate a script in all open webview windows. -fn eval_in_all_windows(app: &tauri::AppHandle, script: &str) { - for window in app.webview_windows().values() { - let _ = window.eval(script); - } -} - -/// Evaluate a script in the currently focused window, falling back to any window. -fn eval_in_focused_window(app: &tauri::AppHandle, script: &str) { - let windows = app.webview_windows(); - // Try the focused window first. - for window in windows.values() { - if window.is_focused().unwrap_or(false) { - let _ = window.eval(script); - return; - } - } - // Fallback: try "main", then any window. - if let Some(window) = windows.get("main") { - let _ = window.eval(script); - } else if let Some(window) = windows.values().next() { - let _ = window.eval(script); - } -} - -fn dispatch_menu_action(app: &tauri::AppHandle, action: &str) { - let _ = app.emit("openchamber:menu-action", action); - - let event = serde_json::to_string("openchamber:menu-action") - .unwrap_or_else(|_| "\"openchamber:menu-action\"".into()); - let detail = serde_json::to_string(action).unwrap_or_else(|_| "\"\"".into()); - let script = format!("window.dispatchEvent(new CustomEvent({event}, {{ detail: {detail} }}));"); - eval_in_focused_window(app, &script); -} - -fn dispatch_check_for_updates(app: &tauri::AppHandle) { - let _ = app.emit("openchamber:check-for-updates", ()); - - let event = serde_json::to_string("openchamber:check-for-updates") - .unwrap_or_else(|_| "\"openchamber:check-for-updates\"".into()); - let script = format!("window.dispatchEvent(new Event({event}));"); - eval_in_all_windows(app, &script); -} -use tauri_plugin_shell::{process::CommandChild, process::CommandEvent, ShellExt}; -use tauri_plugin_updater::UpdaterExt; - -#[cfg(target_os = "macos")] -const MENU_ITEM_ABOUT_ID: &str = "menu_about"; -#[cfg(target_os = "macos")] -const MENU_ITEM_CHECK_FOR_UPDATES_ID: &str = "menu_check_for_updates"; -#[cfg(target_os = "macos")] -const MENU_ITEM_NEW_WINDOW_ID: &str = "menu_new_window"; -#[cfg(target_os = "macos")] -const MENU_ITEM_SETTINGS_ID: &str = "menu_settings"; -#[cfg(target_os = "macos")] -const MENU_ITEM_COMMAND_PALETTE_ID: &str = "menu_command_palette"; -#[cfg(target_os = "macos")] -#[cfg(target_os = "macos")] -const MENU_ITEM_NEW_SESSION_ID: &str = "menu_new_session"; -#[cfg(target_os = "macos")] -const MENU_ITEM_WORKTREE_CREATOR_ID: &str = "menu_worktree_creator"; -#[cfg(target_os = "macos")] -const MENU_ITEM_CHANGE_WORKSPACE_ID: &str = "menu_change_workspace"; -#[cfg(target_os = "macos")] -const MENU_ITEM_OPEN_GIT_TAB_ID: &str = "menu_open_git_tab"; -#[cfg(target_os = "macos")] -const MENU_ITEM_OPEN_DIFF_TAB_ID: &str = "menu_open_diff_tab"; -#[cfg(target_os = "macos")] -const MENU_ITEM_OPEN_FILES_TAB_ID: &str = "menu_open_files_tab"; -#[cfg(target_os = "macos")] -const MENU_ITEM_OPEN_TERMINAL_TAB_ID: &str = "menu_open_terminal_tab"; -#[cfg(target_os = "macos")] -const MENU_ITEM_COPY_ID: &str = "menu_copy"; -#[cfg(target_os = "macos")] -const MENU_ITEM_THEME_LIGHT_ID: &str = "menu_theme_light"; -#[cfg(target_os = "macos")] -const MENU_ITEM_THEME_DARK_ID: &str = "menu_theme_dark"; -#[cfg(target_os = "macos")] -const MENU_ITEM_THEME_SYSTEM_ID: &str = "menu_theme_system"; -#[cfg(target_os = "macos")] -const MENU_ITEM_TOGGLE_SIDEBAR_ID: &str = "menu_toggle_sidebar"; -#[cfg(target_os = "macos")] -const MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID: &str = "menu_toggle_memory_debug"; -#[cfg(target_os = "macos")] -const MENU_ITEM_HELP_DIALOG_ID: &str = "menu_help_dialog"; -#[cfg(target_os = "macos")] -const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "menu_download_logs"; -#[cfg(target_os = "macos")] -const MENU_ITEM_REPORT_BUG_ID: &str = "menu_report_bug"; -#[cfg(target_os = "macos")] -const MENU_ITEM_REQUEST_FEATURE_ID: &str = "menu_request_feature"; -#[cfg(target_os = "macos")] -const MENU_ITEM_JOIN_DISCORD_ID: &str = "menu_join_discord"; -#[cfg(target_os = "macos")] -const MENU_ITEM_CLEAR_CACHE_ID: &str = "menu_clear_cache"; -#[cfg(target_os = "macos")] -const MENU_ITEM_QUIT_ID: &str = "menu_quit"; - -#[cfg(target_os = "macos")] -const GITHUB_BUG_REPORT_URL: &str = - "https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml"; -#[cfg(target_os = "macos")] -const GITHUB_FEATURE_REQUEST_URL: &str = - "https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml"; -#[cfg(target_os = "macos")] -const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA"; - -static QUIT_CONFIRMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -static QUIT_CONFIRMATION_PENDING: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); -static QUIT_RISK_HAS_ENABLED_SCHEDULED_TASKS: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); -static QUIT_RISK_HAS_RUNNING_SCHEDULED_TASKS: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); -static QUIT_RISK_ENABLED_SCHEDULED_TASKS_COUNT: AtomicU32 = AtomicU32::new(0); -static QUIT_RISK_RUNNING_SCHEDULED_TASKS_COUNT: AtomicU32 = AtomicU32::new(0); -static QUIT_RISK_HAS_ACTIVE_TUNNEL: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); -static QUIT_RISK_POLLER_STARTED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - -#[cfg(target_os = "macos")] -const QUIT_RISK_POLL_INTERVAL: Duration = Duration::from_secs(5); - -#[cfg(target_os = "macos")] -fn should_require_quit_confirmation() -> bool { - use std::sync::atomic::Ordering; - - QUIT_RISK_HAS_ACTIVE_TUNNEL.load(Ordering::Relaxed) - || QUIT_RISK_HAS_RUNNING_SCHEDULED_TASKS.load(Ordering::Relaxed) - || QUIT_RISK_HAS_ENABLED_SCHEDULED_TASKS.load(Ordering::Relaxed) -} - -#[cfg(target_os = "macos")] -fn quit_confirmation_message() -> String { - use std::sync::atomic::Ordering; - - let has_active_tunnel = QUIT_RISK_HAS_ACTIVE_TUNNEL.load(Ordering::Relaxed); - let running_tasks_count = QUIT_RISK_RUNNING_SCHEDULED_TASKS_COUNT.load(Ordering::Relaxed); - let enabled_tasks_count = QUIT_RISK_ENABLED_SCHEDULED_TASKS_COUNT.load(Ordering::Relaxed); - - let mut reasons: Vec = Vec::new(); - if has_active_tunnel { - reasons.push("an active tunnel".to_string()); - } - if running_tasks_count > 0 { - reasons.push(format!( - "{} running scheduled task{}", - running_tasks_count, - if running_tasks_count == 1 { "" } else { "s" } - )); - } - if enabled_tasks_count > 0 { - reasons.push(format!( - "{} enabled scheduled task{}", - enabled_tasks_count, - if enabled_tasks_count == 1 { "" } else { "s" } - )); - } - - if reasons.is_empty() { - "Background processes (sidecar, SSH sessions) will be stopped.".to_string() - } else { - format!( - "OpenChamber detected {}. Quitting now will stop sidecar/background processes and may interrupt pending work.", - reasons.join(", ") - ) - } -} - -#[cfg(target_os = "macos")] -const NS_TERMINATE_CANCEL: isize = 0; -#[cfg(target_os = "macos")] -const NS_TERMINATE_NOW: isize = 1; - -#[cfg(target_os = "macos")] -unsafe extern "C-unwind" fn application_should_terminate_with_confirmation( - _: &objc2::runtime::AnyObject, - _: objc2::runtime::Sel, - _: *mut std::ffi::c_void, -) -> isize { - use std::sync::atomic::Ordering; - - if QUIT_CONFIRMED.load(Ordering::SeqCst) { - return NS_TERMINATE_NOW; - } - - if !should_require_quit_confirmation() { - QUIT_CONFIRMED.store(true, Ordering::SeqCst); - return NS_TERMINATE_NOW; - } - - if QUIT_CONFIRMATION_PENDING.swap(true, Ordering::SeqCst) { - return NS_TERMINATE_CANCEL; - } - - let message = quit_confirmation_message(); - let confirmed = matches!( - rfd::MessageDialog::new() - .set_title("Quit OpenChamber?") - .set_description(&message) - .set_level(rfd::MessageLevel::Warning) - .set_buttons(rfd::MessageButtons::OkCancel) - .show(), - rfd::MessageDialogResult::Ok | rfd::MessageDialogResult::Yes - ); - - QUIT_CONFIRMATION_PENDING.store(false, Ordering::SeqCst); - - if confirmed { - QUIT_CONFIRMED.store(true, Ordering::SeqCst); - NS_TERMINATE_NOW - } else { - NS_TERMINATE_CANCEL - } -} - -#[cfg(target_os = "macos")] -fn install_macos_quit_confirmation_hook() { - use objc2::ffi; - use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; - use std::ffi::CStr; - - unsafe { - let Some(delegate_class) = AnyClass::get(CStr::from_bytes_with_nul_unchecked( - b"TaoAppDelegateParent\0", - )) else { - log::warn!("[desktop] TaoAppDelegateParent class not found; dock Quit confirmation hook skipped"); - return; - }; - - let selector = Sel::register(c"applicationShouldTerminate:"); - if !ffi::class_getInstanceMethod(delegate_class, selector).is_null() { - return; - } - - let imp: Imp = std::mem::transmute( - application_should_terminate_with_confirmation - as unsafe extern "C-unwind" fn(&AnyObject, Sel, *mut std::ffi::c_void) -> isize, - ); - - let added = ffi::class_addMethod( - delegate_class as *const _ as *mut _, - selector, - imp, - b"q@:@\0".as_ptr().cast(), - ); - - if !added.as_bool() { - log::warn!("[desktop] failed to install applicationShouldTerminate hook"); - } - } -} - -#[cfg(not(target_os = "macos"))] -fn install_macos_quit_confirmation_hook() {} - -#[cfg(target_os = "macos")] -fn request_quit_with_confirmation(app: &tauri::AppHandle) { - use std::sync::atomic::Ordering; - use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; - - if !should_require_quit_confirmation() { - QUIT_CONFIRMED.store(true, Ordering::SeqCst); - app.exit(0); - return; - } - - if QUIT_CONFIRMATION_PENDING.swap(true, Ordering::SeqCst) { - return; - } - - // When app has only hidden windows (common after closing last window), - // ensure at least one window is visible so native dialog reliably appears. - let windows = app.webview_windows(); - let has_visible = windows.values().any(|w| w.is_visible().unwrap_or(false)); - if !has_visible { - if let Some(hidden) = windows.values().find(|w| !w.is_visible().unwrap_or(true)) { - let _ = hidden.show(); - let _ = hidden.set_focus(); - } - } - - let message = quit_confirmation_message(); - let handle = app.clone(); - app.dialog() - .message(message) - .title("Quit OpenChamber?") - .buttons(MessageDialogButtons::OkCancel) - .kind(tauri_plugin_dialog::MessageDialogKind::Warning) - .show(move |confirmed| { - QUIT_CONFIRMATION_PENDING.store(false, Ordering::SeqCst); - if confirmed { - QUIT_CONFIRMED.store(true, Ordering::SeqCst); - handle.exit(0); - } - }); -} - -#[cfg(target_os = "macos")] -fn build_macos_menu( - app: &tauri::AppHandle, -) -> tauri::Result> { - use tauri::menu::{ - Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, - }; - - let pkg_info = app.package_info(); - - let new_session_shortcut = "Cmd+N"; - let new_worktree_shortcut = "Cmd+Shift+N"; - - let about = MenuItem::with_id( - app, - MENU_ITEM_ABOUT_ID, - format!("About {}", pkg_info.name), - true, - None::<&str>, - )?; - - let check_for_updates = MenuItem::with_id( - app, - MENU_ITEM_CHECK_FOR_UPDATES_ID, - "Check for Updates", - true, - None::<&str>, - )?; - - let settings = MenuItem::with_id(app, MENU_ITEM_SETTINGS_ID, "Settings", true, Some("Cmd+,"))?; - - let command_palette = MenuItem::with_id( - app, - MENU_ITEM_COMMAND_PALETTE_ID, - "Command Palette", - true, - Some("Cmd+P"), - )?; - - let new_window = MenuItem::with_id( - app, - MENU_ITEM_NEW_WINDOW_ID, - "New Window", - true, - Some("Cmd+Shift+Alt+N"), - )?; - - let new_session = MenuItem::with_id( - app, - MENU_ITEM_NEW_SESSION_ID, - "New Session", - true, - Some(new_session_shortcut), - )?; - - let worktree_creator = MenuItem::with_id( - app, - MENU_ITEM_WORKTREE_CREATOR_ID, - "New Worktree", - true, - Some(new_worktree_shortcut), - )?; - - let change_workspace = MenuItem::with_id( - app, - MENU_ITEM_CHANGE_WORKSPACE_ID, - "Add Workspace", - true, - None::<&str>, - )?; - - let open_git_tab = - MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Cmd+G"))?; - let open_diff_tab = - MenuItem::with_id(app, MENU_ITEM_OPEN_DIFF_TAB_ID, "Diff", true, Some("Cmd+E"))?; - let open_files_tab = MenuItem::with_id( - app, - MENU_ITEM_OPEN_FILES_TAB_ID, - "Files", - true, - None::<&str>, - )?; - let open_terminal_tab = MenuItem::with_id( - app, - MENU_ITEM_OPEN_TERMINAL_TAB_ID, - "Terminal", - true, - Some("Cmd+T"), - )?; - let copy = MenuItem::with_id(app, MENU_ITEM_COPY_ID, "Copy", true, Some("Cmd+C"))?; - - let theme_light = MenuItem::with_id( - app, - MENU_ITEM_THEME_LIGHT_ID, - "Light Theme", - true, - None::<&str>, - )?; - let theme_dark = MenuItem::with_id( - app, - MENU_ITEM_THEME_DARK_ID, - "Dark Theme", - true, - None::<&str>, - )?; - let theme_system = MenuItem::with_id( - app, - MENU_ITEM_THEME_SYSTEM_ID, - "System Theme", - true, - None::<&str>, - )?; - - let toggle_sidebar = MenuItem::with_id( - app, - MENU_ITEM_TOGGLE_SIDEBAR_ID, - "Toggle Session Sidebar", - true, - Some("Cmd+L"), - )?; - - let toggle_memory_debug = MenuItem::with_id( - app, - MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID, - "Toggle Memory Debug", - true, - Some("CmdOrCtrl+Shift+D"), - )?; - - let help_dialog = MenuItem::with_id( - app, - MENU_ITEM_HELP_DIALOG_ID, - "Keyboard Shortcuts", - true, - Some("Cmd+."), - )?; - - let download_logs = MenuItem::with_id( - app, - MENU_ITEM_DOWNLOAD_LOGS_ID, - "Show Diagnostics", - true, - Some("Cmd+Shift+L"), - )?; - - let report_bug = MenuItem::with_id( - app, - MENU_ITEM_REPORT_BUG_ID, - "Report a Bug", - true, - None::<&str>, - )?; - let request_feature = MenuItem::with_id( - app, - MENU_ITEM_REQUEST_FEATURE_ID, - "Request a Feature", - true, - None::<&str>, - )?; - let join_discord = MenuItem::with_id( - app, - MENU_ITEM_JOIN_DISCORD_ID, - "Join Discord", - true, - None::<&str>, - )?; - - let clear_cache = MenuItem::with_id( - app, - MENU_ITEM_CLEAR_CACHE_ID, - "Clear Cache", - true, - None::<&str>, - )?; - - let theme_submenu = Submenu::with_items( - app, - "Theme", - true, - &[&theme_light, &theme_dark, &theme_system], - )?; - - let window_menu = Submenu::with_id_and_items( - app, - WINDOW_SUBMENU_ID, - "Window", - true, - &[ - &PredefinedMenuItem::minimize(app, None)?, - &PredefinedMenuItem::maximize(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::close_window(app, None)?, - ], - )?; - - let help_menu = Submenu::with_id_and_items( - app, - HELP_SUBMENU_ID, - "Help", - true, - &[ - &help_dialog, - &download_logs, - &PredefinedMenuItem::separator(app)?, - &clear_cache, - &PredefinedMenuItem::separator(app)?, - &report_bug, - &request_feature, - &PredefinedMenuItem::separator(app)?, - &join_discord, - ], - )?; - - Menu::with_items( - app, - &[ - &Submenu::with_items( - app, - pkg_info.name.clone(), - true, - &[ - &about, - &check_for_updates, - &PredefinedMenuItem::separator(app)?, - &settings, - &command_palette, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::services(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::hide(app, None)?, - &PredefinedMenuItem::hide_others(app, None)?, - &PredefinedMenuItem::separator(app)?, - &MenuItem::with_id( - app, - MENU_ITEM_QUIT_ID, - format!("Quit {}", pkg_info.name), - true, - Some("Cmd+Q"), - )?, - ], - )?, - &Submenu::with_items( - app, - "File", - true, - &[ - &new_window, - &PredefinedMenuItem::separator(app)?, - &new_session, - &worktree_creator, - &PredefinedMenuItem::separator(app)?, - &change_workspace, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::close_window(app, None)?, - ], - )?, - &Submenu::with_items( - app, - "Edit", - true, - &[ - &PredefinedMenuItem::undo(app, None)?, - &PredefinedMenuItem::redo(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::cut(app, None)?, - ©, - &PredefinedMenuItem::paste(app, None)?, - &PredefinedMenuItem::select_all(app, None)?, - ], - )?, - &Submenu::with_items( - app, - "View", - true, - &[ - &open_git_tab, - &open_diff_tab, - &open_files_tab, - &open_terminal_tab, - &PredefinedMenuItem::separator(app)?, - &theme_submenu, - &PredefinedMenuItem::separator(app)?, - &toggle_sidebar, - &toggle_memory_debug, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::fullscreen(app, None)?, - ], - )?, - &window_menu, - &help_menu, - ], - ) -} - -#[tauri::command] -fn desktop_clear_cache(app: tauri::AppHandle) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - let mut failures: Vec = Vec::new(); - - for (label, window) in app.webview_windows() { - if let Err(err) = window.clear_all_browsing_data() { - failures.push(format!("{label}: {err}")); - } - } - - if !failures.is_empty() { - return Err(format!( - "Failed to clear browsing data for some windows: {}", - failures.join("; ") - )); - } - - // Reload all windows after clearing persisted browsing data so in-memory state is reset too. - eval_in_all_windows(&app, "window.location.reload();"); - - log::info!("[desktop] Cleared all webview browsing data and reloaded windows"); - return Ok(()); - } - - #[cfg(not(target_os = "macos"))] - { - Err("desktop_clear_cache is only supported on macOS".to_string()) - } -} - -#[tauri::command] -fn desktop_open_path(path: String, app: Option) -> Result<(), String> { - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err("Path is required".to_string()); - } - - #[cfg(target_os = "macos")] - { - let mut command = Command::new("open"); - if let Some(app_name) = app - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - command.arg("-a").arg(app_name); - } - command.arg(trimmed); - command.spawn().map_err(|err| err.to_string())?; - return Ok(()); - } - - #[cfg(not(target_os = "macos"))] - { - Err("desktop_open_path is only supported on macOS".to_string()) - } -} - -#[cfg(target_os = "macos")] -#[derive(Clone)] -struct OpenCommandSpec { - program: &'static str, - args: Vec, -} - -#[cfg(target_os = "macos")] -fn run_open_command_chain(specs: &[OpenCommandSpec]) -> Result<(), String> { - let mut failures: Vec = Vec::new(); - - for spec in specs { - match Command::new(spec.program).args(&spec.args).status() { - Ok(status) if status.success() => return Ok(()), - Ok(status) => failures.push(format!( - "{} {} exited with status {}", - spec.program, - spec.args.join(" "), - status - )), - Err(error) => failures.push(format!( - "{} {} failed: {}", - spec.program, - spec.args.join(" "), - error - )), - } - } - - if failures.is_empty() { - return Err("No launch strategies available".to_string()); - } - - Err(failures.join("; ")) -} - -#[cfg(target_os = "macos")] -fn is_jetbrains_app_id(app_id: &str) -> bool { - matches!( - app_id, - "pycharm" - | "intellij" - | "webstorm" - | "phpstorm" - | "rider" - | "rustrover" - | "android-studio" - ) -} - -#[cfg(target_os = "macos")] -fn cli_for_app_id(app_id: &str) -> Option<&'static str> { - match app_id { - "vscode" => Some("code"), - "cursor" => Some("cursor"), - "vscodium" => Some("codium"), - "windsurf" => Some("windsurf"), - "zed" => Some("zed"), - _ => None, - } -} - -#[tauri::command] -fn desktop_open_in_app( - project_path: String, - app_id: String, - app_name: String, - file_path: Option, -) -> Result<(), String> { - let trimmed_project_path = project_path.trim(); - if trimmed_project_path.is_empty() { - return Err("Project path is required".to_string()); - } - - let trimmed_app_id = app_id.trim().to_lowercase(); - if trimmed_app_id.is_empty() { - return Err("App id is required".to_string()); - } - - let trimmed_app_name = app_name.trim(); - if trimmed_app_name.is_empty() { - return Err("App name is required".to_string()); - } - - let normalized_file_path = file_path - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()); - - #[cfg(target_os = "macos")] - { - let project = trimmed_project_path.to_string(); - let app_name_owned = trimmed_app_name.to_string(); - let file = normalized_file_path.map(|value| value.to_string()); - let mut specs: Vec = Vec::new(); - - if trimmed_app_id == "finder" { - specs.push(OpenCommandSpec { - program: "open", - args: vec![project.clone()], - }); - return run_open_command_chain(&specs); - } - - if matches!(trimmed_app_id.as_str(), "terminal" | "iterm2" | "ghostty") { - specs.push(OpenCommandSpec { - program: "open", - args: vec!["-a".to_string(), app_name_owned.clone(), project.clone()], - }); - return run_open_command_chain(&specs); - } - - if let Some(cli) = cli_for_app_id(trimmed_app_id.as_str()) { - let mut cli_args = vec!["-n".to_string(), project.clone()]; - if let Some(file_path) = file.as_ref() { - cli_args.push("-g".to_string()); - cli_args.push(file_path.clone()); - } - specs.push(OpenCommandSpec { - program: cli, - args: cli_args, - }); - } - - if is_jetbrains_app_id(trimmed_app_id.as_str()) { - let mut args = vec![ - "-na".to_string(), - app_name_owned.clone(), - "--args".to_string(), - project.clone(), - ]; - if let Some(file_path) = file.as_ref() { - args.push(file_path.clone()); - } - specs.push(OpenCommandSpec { - program: "open", - args, - }); - } - - if let Some(file_path) = file.as_ref() { - specs.push(OpenCommandSpec { - program: "open", - args: vec![ - "-na".to_string(), - app_name_owned.clone(), - "--args".to_string(), - project.clone(), - file_path.clone(), - ], - }); - } - - specs.push(OpenCommandSpec { - program: "open", - args: vec!["-a".to_string(), app_name_owned.clone(), project.clone()], - }); - - if let Some(file_path) = file { - specs.push(OpenCommandSpec { - program: "open", - args: vec!["-a".to_string(), app_name_owned, file_path], - }); - } - - return run_open_command_chain(&specs); - } - - #[cfg(not(target_os = "macos"))] - { - let _ = normalized_file_path; - Err("desktop_open_in_app is only supported on macOS".to_string()) - } -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -struct InstalledAppInfo { - name: String, - icon_data_url: Option, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct InstalledAppsCache { - updated_at: u64, - apps: Vec, -} - -const INSTALLED_APPS_CACHE_TTL_SECS: u64 = 60 * 60 * 24; -const INSTALLED_APPS_CACHE_FILE: &str = "discovered-apps.json"; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct InstalledAppsResponse { - apps: Vec, - has_cache: bool, - is_cache_stale: bool, -} - -#[tauri::command] -fn desktop_filter_installed_apps(apps: Vec) -> Result, String> { - #[cfg(target_os = "macos")] - { - let mut installed: Vec = Vec::new(); - - for raw in apps { - let trimmed = raw.trim(); - if trimmed.is_empty() { - continue; - } - - let bundle_name = if trimmed.ends_with(".app") { - trimmed.to_string() - } else { - format!("{trimmed}.app") - }; - - if is_app_bundle_installed(&bundle_name) { - installed.push(trimmed.to_string()); - } - } - - return Ok(installed); - } - - #[cfg(not(target_os = "macos"))] - { - let _ = apps; - Err("desktop_filter_installed_apps is only supported on macOS".to_string()) - } -} - -#[tauri::command] -fn desktop_get_installed_apps( - app: tauri::AppHandle, - apps: Vec, - force: Option, -) -> Result { - #[cfg(target_os = "macos")] - { - let cache_path = installed_apps_cache_path(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|err| err.to_string())? - .as_secs(); - - let cache = read_installed_apps_cache(&cache_path); - let cached_apps = cache - .as_ref() - .map(|entry| entry.apps.clone()) - .unwrap_or_default(); - let has_cache = cache.is_some(); - let is_cache_stale = cache - .as_ref() - .map(|entry| now.saturating_sub(entry.updated_at) > INSTALLED_APPS_CACHE_TTL_SECS) - .unwrap_or(false); - - if has_cache { - if is_cache_stale { - log::info!("[open-in] cache hit (stale): {} apps", cached_apps.len()); - } else { - log::info!("[open-in] cache hit (fresh): {} apps", cached_apps.len()); - } - if log::log_enabled!(log::Level::Info) { - let names: Vec = cached_apps.iter().map(|app| app.name.clone()).collect(); - log::info!("[open-in] cache apps: {:?}", names); - } - } - - if !has_cache { - log::info!("[open-in] cache missing: refreshing app list"); - let app_handle = app.clone(); - let app_names = apps.clone(); - let force_icon_refresh = false; - let cached_icon_map: HashMap = HashMap::new(); - tauri::async_runtime::spawn_blocking(move || { - log::info!("[open-in] scan start: {} candidates", app_names.len()); - let refreshed = - build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh); - if log::log_enabled!(log::Level::Info) { - let names: Vec = - refreshed.iter().map(|entry| entry.name.clone()).collect(); - log::info!("[open-in] scan apps: {:?}", names); - } - log::info!("[open-in] scan done: {} installed", refreshed.len()); - let cache_entry = InstalledAppsCache { - updated_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|value| value.as_secs()) - .unwrap_or(0), - apps: refreshed.clone(), - }; - let cache_path = installed_apps_cache_path(); - let _ = write_installed_apps_cache(&cache_path, &cache_entry); - dispatch_installed_apps_update(&app_handle, &refreshed); - }); - } else if force.unwrap_or(false) { - log::info!("[open-in] manual refresh: refreshing app list"); - let app_handle = app.clone(); - let app_names = apps.clone(); - let force_icon_refresh = true; - let cached_icon_map: HashMap = HashMap::new(); - tauri::async_runtime::spawn_blocking(move || { - log::info!("[open-in] scan start: {} candidates", app_names.len()); - let refreshed = - build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh); - if log::log_enabled!(log::Level::Info) { - let names: Vec = - refreshed.iter().map(|entry| entry.name.clone()).collect(); - log::info!("[open-in] scan apps: {:?}", names); - } - log::info!("[open-in] scan done: {} installed", refreshed.len()); - let cache_entry = InstalledAppsCache { - updated_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|value| value.as_secs()) - .unwrap_or(0), - apps: refreshed.clone(), - }; - let cache_path = installed_apps_cache_path(); - let _ = write_installed_apps_cache(&cache_path, &cache_entry); - dispatch_installed_apps_update(&app_handle, &refreshed); - }); - } - - return Ok(InstalledAppsResponse { - apps: cached_apps, - has_cache, - is_cache_stale, - }); - } - - #[cfg(not(target_os = "macos"))] - { - let _ = apps; - Err("desktop_get_installed_apps is only supported on macOS".to_string()) - } -} - -#[derive(Serialize)] -struct AppIconPayload { - app: String, - data_url: String, -} - -#[tauri::command] -fn desktop_fetch_app_icons(apps: Vec) -> Result, String> { - #[cfg(target_os = "macos")] - { - let mut results: Vec = Vec::new(); - - for raw in apps { - let trimmed = raw.trim(); - if trimmed.is_empty() { - continue; - } - - let Some(app_path) = resolve_app_bundle_path(trimmed) else { - continue; - }; - - let Some(icon_path) = resolve_app_icon_path(&app_path) else { - continue; - }; - - let Some(data_url) = icon_to_data_url(&icon_path, trimmed) else { - continue; - }; - - results.push(AppIconPayload { - app: trimmed.to_string(), - data_url, - }); - } - - return Ok(results); - } - - #[cfg(not(target_os = "macos"))] - { - let _ = apps; - Err("desktop_fetch_app_icons is only supported on macOS".to_string()) - } -} - -#[cfg(target_os = "macos")] -fn resolve_app_bundle_path(app_name: &str) -> Option { - if app_name.trim().is_empty() { - return None; - } - - let bundle_name = if app_name.ends_with(".app") { - app_name.to_string() - } else { - format!("{app_name}.app") - }; - - let candidates = [ - format!("/Applications/{bundle_name}"), - format!("/System/Applications/{bundle_name}"), - format!("/System/Applications/Utilities/{bundle_name}"), - ]; - - for candidate in candidates { - let path = PathBuf::from(&candidate); - if path.exists() { - return Some(path); - } - } - - if let Some(home) = env::var_os("HOME") { - let user_app_path = PathBuf::from(home).join("Applications").join(&bundle_name); - if user_app_path.exists() { - return Some(user_app_path); - } - } - - if let Ok(output) = Command::new("mdfind") - .args(["-name", &bundle_name]) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let path = PathBuf::from(trimmed); - if path.exists() { - return Some(path); - } - } - } - } - - None -} - -#[cfg(target_os = "macos")] -fn installed_apps_cache_path() -> PathBuf { - let home = env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/")); - home.join(".config") - .join("openchamber") - .join(INSTALLED_APPS_CACHE_FILE) -} - -#[cfg(target_os = "macos")] -fn read_installed_apps_cache(path: &Path) -> Option { - let bytes = fs::read(path).ok()?; - serde_json::from_slice(&bytes).ok() -} - -#[cfg(target_os = "macos")] -fn write_installed_apps_cache(path: &Path, cache: &InstalledAppsCache) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|err| err.to_string())?; - } - let payload = serde_json::to_vec(cache).map_err(|err| err.to_string())?; - fs::write(path, payload).map_err(|err| err.to_string()) -} - -#[cfg(target_os = "macos")] -fn build_installed_apps( - apps: &[String], - cached_icon_map: &HashMap, - force_icon_refresh: bool, -) -> Vec { - let mut seen = HashSet::new(); - let mut results = Vec::new(); - - for raw in apps { - let trimmed = raw.trim(); - if trimmed.is_empty() || !seen.insert(trimmed.to_string()) { - continue; - } - - if let Some(app_path) = resolve_app_bundle_path(trimmed) { - let icon_data_url = if force_icon_refresh { - resolve_app_icon_path(&app_path).and_then(|icon| icon_to_data_url(&icon, trimmed)) - } else { - cached_icon_map.get(trimmed).cloned().or_else(|| { - resolve_app_icon_path(&app_path) - .and_then(|icon| icon_to_data_url(&icon, trimmed)) - }) - }; - results.push(InstalledAppInfo { - name: trimmed.to_string(), - icon_data_url, - }); - } - } - - results -} - -#[cfg(target_os = "macos")] -fn dispatch_installed_apps_update(app: &tauri::AppHandle, apps: &[InstalledAppInfo]) { - let event = serde_json::to_string("openchamber:installed-apps-updated") - .unwrap_or_else(|_| "\"openchamber:installed-apps-updated\"".into()); - let detail = serde_json::to_string(apps).unwrap_or_else(|_| "[]".into()); - let script = format!("window.dispatchEvent(new CustomEvent({event}, {{ detail: {detail} }}));"); - eval_in_all_windows(app, &script); -} - -#[cfg(target_os = "macos")] -fn resolve_app_icon_path(app_path: &Path) -> Option { - if !app_path.exists() { - return None; - } - - if let Some(icon_file) = read_bundle_icon_file(app_path) { - let icon_path = app_path.join("Contents").join("Resources").join(&icon_file); - if icon_path.exists() { - return Some(icon_path); - } - } - - if let Ok(output) = Command::new("mdls") - .args([ - "-name", - "kMDItemIconFile", - "-raw", - &app_path.to_string_lossy(), - ]) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let icon_name = stdout.trim(); - if !icon_name.is_empty() && icon_name != "(null)" { - let icon_file = if icon_name.ends_with(".icns") { - icon_name.to_string() - } else { - format!("{icon_name}.icns") - }; - let icon_path = app_path.join("Contents").join("Resources").join(icon_file); - if icon_path.exists() { - return Some(icon_path); - } - } - } - } - - let resources_path = app_path.join("Contents").join("Resources"); - if let Ok(entries) = fs::read_dir(resources_path) { - for entry in entries.flatten() { - let path = entry.path(); - if let Some(ext) = path.extension().and_then(|value| value.to_str()) { - if ext.eq_ignore_ascii_case("icns") { - return Some(path); - } - } - } - } - - None -} - -#[cfg(target_os = "macos")] -fn read_bundle_icon_file(app_path: &Path) -> Option { - let plist_path = app_path.join("Contents").join("Info.plist"); - if !plist_path.exists() { - return None; - } - - let output = Command::new("defaults") - .args(["read", &plist_path.to_string_lossy(), "CFBundleIconFile"]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let icon_name = stdout.trim(); - if icon_name.is_empty() { - return None; - } - - let icon_file = if icon_name.ends_with(".icns") { - icon_name.to_string() - } else { - format!("{icon_name}.icns") - }; - - Some(icon_file) -} - -#[cfg(target_os = "macos")] -fn icon_to_data_url(icon_path: &Path, app_name: &str) -> Option { - if !icon_path.exists() { - return None; - } - - let sanitized: String = app_name - .chars() - .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) - .collect(); - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|value| value.as_millis()) - .unwrap_or(0); - let tmp_path = env::temp_dir().join(format!("openchamber-icon-{sanitized}-{timestamp}.png")); - - let status = Command::new("sips") - .args([ - "-s", - "format", - "png", - "-Z", - "32", - &icon_path.to_string_lossy(), - "--out", - &tmp_path.to_string_lossy(), - ]) - .status() - .ok()?; - - if !status.success() { - return None; - } - - let bytes = fs::read(&tmp_path).ok()?; - let _ = fs::remove_file(&tmp_path); - if bytes.is_empty() { - return None; - } - - let encoded = general_purpose::STANDARD.encode(bytes); - Some(format!("data:image/png;base64,{encoded}")) -} - -#[cfg(target_os = "macos")] -fn is_app_bundle_installed(bundle_name: &str) -> bool { - if bundle_name.trim().is_empty() { - return false; - } - - if let Ok(output) = Command::new("mdfind").args(["-name", bundle_name]).output() { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.trim().is_empty() { - return true; - } - } - } - - let app_path = format!("/Applications/{bundle_name}"); - let system_app_path = format!("/System/Applications/{bundle_name}"); - let utilities_path = format!("/System/Applications/Utilities/{bundle_name}"); - - if Path::new(&app_path).exists() - || Path::new(&system_app_path).exists() - || Path::new(&utilities_path).exists() - { - return true; - } - - if let Some(home) = env::var_os("HOME") { - let user_app_path = PathBuf::from(home).join("Applications").join(bundle_name); - if user_app_path.exists() { - return true; - } - } - - false -} - -const SIDECAR_NAME: &str = "openchamber-server"; -const SIDECAR_NOTIFY_PREFIX: &str = "[OpenChamberDesktopNotify] "; -const HEALTH_TIMEOUT: Duration = Duration::from_secs(20); -const HEALTH_POLL_INITIAL_INTERVAL: Duration = Duration::from_millis(250); -const HEALTH_POLL_MAX_INTERVAL: Duration = Duration::from_millis(2000); -const LOCAL_SIDECAR_HEALTH_TIMEOUT: Duration = Duration::from_secs(8); -const LOCAL_SIDECAR_HEALTH_POLL_INITIAL_INTERVAL: Duration = Duration::from_millis(100); -const LOCAL_SIDECAR_HEALTH_POLL_MAX_INTERVAL: Duration = Duration::from_millis(1000); -const STARTUP_REMOTE_PROBE_SOFT_TIMEOUT: Duration = Duration::from_secs(2); -const STARTUP_REMOTE_PROBE_HARD_TIMEOUT: Duration = Duration::from_secs(10); - -const DEFAULT_DESKTOP_PORT: u16 = 57123; -const WINDOW_STATE_DEBOUNCE_MS: u64 = 300; -const MIN_WINDOW_WIDTH: u32 = 800; -const MIN_WINDOW_HEIGHT: u32 = 520; -const MIN_RESTORE_WINDOW_WIDTH: u32 = 900; -const MIN_RESTORE_WINDOW_HEIGHT: u32 = 560; - -const LOCAL_HOST_ID: &str = "local"; - -/// Synthetic host ID used when the boot target is forced via -/// `OPENCHAMBER_SERVER_URL` (no config-based host entry). -const ENV_OVERRIDE_HOST_ID: &str = "__env"; - -/// Synthetic host ID used when a window is opened at an explicit URL -/// via `desktop_new_window_at_url` (no config-based host entry). -const DIRECT_URL_HOST_ID: &str = "__direct"; - -/// Compare two URL strings for "same server" identity using normalized -/// origin + path. This avoids misclassification when one URL has a -/// trailing slash and the other does not (e.g. `OPENCHAMBER_SERVER_URL` -/// pointing at the local sidecar without a trailing `/`). -fn same_server_url(a: &str, b: &str) -> bool { - let parsed_a = url::Url::parse(a); - let parsed_b = url::Url::parse(b); - match (parsed_a, parsed_b) { - (Ok(a), Ok(b)) => { - a.origin() == b.origin() - && a.path().trim_end_matches('/') == b.path().trim_end_matches('/') - } - _ => a == b, - } -} - -#[derive(Default)] -struct SidecarState { - child: Mutex>, - url: Mutex>, -} - -/// Holds per-window initialization scripts and a global local origin. -/// Each window gets its own init script (containing the correct boot outcome -/// for that window's target URL), so page reloads re-inject the right data. -#[derive(Default)] -struct DesktopUiInjectionState { - /// Init scripts keyed by window label. Each window's script contains - /// the correct `__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__` for that window. - scripts: Mutex>, - /// Local origin — shared across all windows since the sidecar is global. - local_origin: Mutex>, -} - -/// Tracks the set of currently-focused window labels. -/// Notification suppression triggers when ANY window is focused. -struct WindowFocusState { - focused_windows: Mutex>, -} - -impl Default for WindowFocusState { - fn default() -> Self { - Self { - focused_windows: Mutex::new(HashSet::new()), - } - } -} - -impl WindowFocusState { - fn any_focused(&self) -> bool { - let guard = self.focused_windows.lock().expect("focus mutex"); - !guard.is_empty() - } - - fn set_focused(&self, label: &str, focused: bool) { - let mut guard = self.focused_windows.lock().expect("focus mutex"); - if focused { - guard.insert(label.to_string()); - } else { - guard.remove(label); - } - } - - fn remove_window(&self, label: &str) { - let mut guard = self.focused_windows.lock().expect("focus mutex"); - guard.remove(label); - } -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DesktopHost { - id: String, - label: String, - url: String, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DesktopHostsConfig { - hosts: Vec, - default_host_id: Option, - #[serde(default)] - initial_host_choice_completed: bool, -} - -/// Input type for `desktop_hosts_set`. Fields may be omitted to preserve -/// existing stored values, ensuring backward-compatible callers don't -/// accidentally reset onboarding state. -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DesktopHostsConfigInput { - hosts: Vec, - default_host_id: Option, - #[serde(default)] - initial_host_choice_completed: Option, -} - -/// Process-wide mutex serializing all read-modify-write operations on the -/// desktop `settings.json`. This prevents concurrent writers (host config, -/// local port, window state, vibrancy, onboarding flag) from clobbering -/// each other's independent fields. -static SETTINGS_FILE_MUTEX: Mutex<()> = Mutex::new(()); - -/// Merge a partial input into an existing config, preserving fields that -/// the caller omitted (`None`). This is the single source of truth for -/// the merge semantics used by `desktop_hosts_set`. -fn merge_desktop_hosts_config( - existing: &DesktopHostsConfig, - input: &DesktopHostsConfigInput, -) -> DesktopHostsConfig { - DesktopHostsConfig { - hosts: input.hosts.clone(), - default_host_id: input.default_host_id.clone(), - initial_host_choice_completed: input - .initial_host_choice_completed - .unwrap_or(existing.initial_host_choice_completed), - } -} - -/// Atomic read-merge-write: reads existing config from `path`, merges -/// `input` into it, and writes the result — all while holding the process -/// lock. Tests and the `desktop_hosts_set` command share this path. -fn write_desktop_hosts_config_input_to_path(path: &Path, input: &DesktopHostsConfigInput) -> Result<()> { - let _guard = SETTINGS_FILE_MUTEX.lock().expect("desktop hosts mutex"); - let existing = read_desktop_hosts_config_from_path(path); - let merged = merge_desktop_hosts_config(&existing, input); - write_desktop_hosts_config_to_path(path, &merged) -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DesktopWindowState { - x: i32, - y: i32, - width: u32, - height: u32, - #[serde(default)] - maximized: bool, - #[serde(default)] - fullscreen: bool, -} - -#[derive(Default)] -struct WindowGeometryDebounceState { - revisions: Mutex>, -} - -fn normalize_host_url(raw: &str) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - let parsed = url::Url::parse(trimmed).ok()?; - let scheme = parsed.scheme(); - if scheme != "http" && scheme != "https" { - return None; - } - let host = parsed.host_str()?; - let mut normalized = format!("{}://{}", scheme, host); - if let Some(port) = parsed.port() { - normalized.push(':'); - normalized.push_str(&port.to_string()); - } - let path = parsed.path(); - if path.is_empty() { - normalized.push('/'); - } else { - normalized.push_str(path); - } - if let Some(query) = parsed.query() { - normalized.push('?'); - normalized.push_str(query); - } - Some(normalized) -} - -fn sanitize_host_url_for_storage(raw: &str) -> Option { - normalize_host_url(raw) -} - -fn build_health_url(base_url: &str) -> Option { - let normalized = normalize_host_url(base_url)?; - let mut parsed = url::Url::parse(&normalized).ok()?; - let current_path = parsed.path(); - let trimmed_path = current_path.trim_end_matches('/'); - let health_path = if trimmed_path.is_empty() { - "/health".to_string() - } else { - format!("{trimmed_path}/health") - }; - parsed.set_path(&health_path); - Some(parsed.to_string()) -} - -fn settings_file_path() -> PathBuf { - if let Ok(dir) = env::var("OPENCHAMBER_DATA_DIR") { - if !dir.trim().is_empty() { - return PathBuf::from(dir.trim()).join("settings.json"); - } - } - let home = env::var("HOME").unwrap_or_default(); - PathBuf::from(home) - .join(".config") - .join("openchamber") - .join("settings.json") -} - -fn read_desktop_settings_json() -> Option { - fs::read_to_string(settings_file_path()) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()) -} - -fn read_desktop_local_port_from_disk() -> Option { - read_desktop_settings_json() - .as_ref() - .and_then(|v| v.get("desktopLocalPort")) - .and_then(|v| v.as_u64()) - .and_then(|v| { - if v > 0 && v <= u16::MAX as u64 { - Some(v as u16) - } else { - None - } - }) -} - -fn write_desktop_local_port_to_disk(port: u16) -> Result<()> { - let _guard = SETTINGS_FILE_MUTEX.lock().expect("settings file mutex"); - let path = settings_file_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let mut root: serde_json::Value = if let Ok(raw) = fs::read_to_string(&path) { - serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) - } else { - serde_json::json!({}) - }; - - if !root.is_object() { - root = serde_json::json!({}); - } - - root["desktopLocalPort"] = serde_json::Value::Number(serde_json::Number::from(port)); - fs::write(&path, serde_json::to_string_pretty(&root)?)?; - Ok(()) -} - -fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig { - read_desktop_hosts_config_from_path(&settings_file_path()) -} - -fn read_desktop_hosts_config_from_path(path: &Path) -> DesktopHostsConfig { - let raw = fs::read_to_string(path).ok(); - let parsed = raw - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()); - - let hosts_value = parsed - .as_ref() - .and_then(|v| v.get("desktopHosts")) - .cloned() - .unwrap_or(serde_json::Value::Null); - let default_value = parsed - .as_ref() - .and_then(|v| v.get("desktopDefaultHostId")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let initial_host_choice_completed = parsed - .as_ref() - .and_then(|v| v.get("desktopInitialHostChoiceCompleted")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let mut hosts: Vec = Vec::new(); - if let serde_json::Value::Array(items) = hosts_value { - for item in items { - if let Ok(host) = serde_json::from_value::(item) { - if host.id.trim().is_empty() || host.id == LOCAL_HOST_ID { - continue; - } - if let Some(url) = sanitize_host_url_for_storage(&host.url) { - hosts.push(DesktopHost { - id: host.id, - label: if host.label.trim().is_empty() { - url.clone() - } else { - host.label - }, - url, - }); - } - } - } - } - - DesktopHostsConfig { - hosts, - default_host_id: default_value, - initial_host_choice_completed, - } -} - -fn read_desktop_window_state_from_disk() -> Option { - let path = settings_file_path(); - let raw = fs::read_to_string(path).ok(); - let parsed = raw - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()); - - parsed - .as_ref() - .and_then(|v| v.get("desktopWindowState")) - .cloned() - .and_then(|v| serde_json::from_value::(v).ok()) -} - -fn write_desktop_window_state_to_disk(state: &DesktopWindowState) -> Result<()> { - let _guard = SETTINGS_FILE_MUTEX.lock().expect("settings file mutex"); - let path = settings_file_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let mut root: serde_json::Value = if let Ok(raw) = fs::read_to_string(&path) { - serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) - } else { - serde_json::json!({}) - }; - - if !root.is_object() { - root = serde_json::json!({}); - } - - root["desktopWindowState"] = serde_json::to_value(state).unwrap_or(serde_json::Value::Null); - fs::write(&path, serde_json::to_string_pretty(&root)?)?; - Ok(()) -} - -fn write_desktop_hosts_config_to_path(path: &Path, config: &DesktopHostsConfig) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let mut root: serde_json::Value = if let Ok(raw) = fs::read_to_string(&path) { - serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) - } else { - serde_json::json!({}) - }; - - if !root.is_object() { - root = serde_json::json!({}); - } - - let hosts: Vec = config - .hosts - .iter() - .filter_map(|h| { - let id = h.id.trim(); - if id.is_empty() || id == LOCAL_HOST_ID { - return None; - } - let url = sanitize_host_url_for_storage(&h.url)?; - Some(DesktopHost { - id: id.to_string(), - label: if h.label.trim().is_empty() { - url.clone() - } else { - h.label.trim().to_string() - }, - url, - }) - }) - .collect(); - - root["desktopHosts"] = serde_json::to_value(hosts).unwrap_or(serde_json::Value::Array(vec![])); - root["desktopDefaultHostId"] = match &config.default_host_id { - Some(id) if !id.trim().is_empty() => serde_json::Value::String(id.trim().to_string()), - _ => serde_json::Value::Null, - }; - root["desktopInitialHostChoiceCompleted"] = - serde_json::Value::Bool(config.initial_host_choice_completed); - - fs::write(&path, serde_json::to_string_pretty(&root)?)?; - Ok(()) -} - -// ── Boot outcome resolution ── - -/// Authoritative desktop boot outcome injected into the webview as -/// `window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__`. -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct DesktopBootOutcome { - target: Option, // "local" | "remote" | null - status: String, // "ok" | "not-configured" | "unreachable" | "wrong-service" | "missing" - #[serde(skip_serializing_if = "Option::is_none")] - host_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, -} - -/// Probe status classification for boot resolution. -#[derive(Clone, Copy, PartialEq, Eq)] -enum ProbeClass { - Ok, - Auth, - Unreachable, - WrongService, - NoProbe, -} - -impl ProbeClass { - fn from_probe(probe: Option<&HostProbeResult>) -> Self { - match probe { - Some(p) if p.status == "ok" => ProbeClass::Ok, - Some(p) if p.status == "auth" => ProbeClass::Auth, - Some(p) if p.status == "wrong-service" => ProbeClass::WrongService, - Some(_) => ProbeClass::Unreachable, - None => ProbeClass::NoProbe, - } - } -} - -/// Result of the shared soft+hard probe policy. -struct ProbeWithRetryResult { - /// Whether the target is navigable (ok or auth). - navigable: bool, - /// The final probe result, if available. - probe: Option, -} - -/// Shared probe policy: soft probe first, hard retry on failure. -/// Used by both startup and open_new_window for consistency. -async fn probe_with_retry(url: &str) -> ProbeWithRetryResult { - let soft_probe = - probe_host_with_timeout(url, STARTUP_REMOTE_PROBE_SOFT_TIMEOUT).await; - - let (navigable, final_probe) = match &soft_probe { - Ok(probe) if matches!(probe.status.as_str(), "ok" | "auth") => { - (true, Some(probe.clone())) - } - Ok(_) => { - log::warn!( - "[desktop] host slow/unreachable ({}), retrying with extended timeout", - url - ); - match probe_host_with_timeout(url, STARTUP_REMOTE_PROBE_HARD_TIMEOUT).await { - Ok(hard_probe) if matches!(hard_probe.status.as_str(), "ok" | "auth") => { - (true, Some(hard_probe)) - } - Ok(hard_probe) => (false, Some(hard_probe)), - Err(_) => (false, None), - } - } - Err(_) => { - log::warn!( - "[desktop] host errored ({}), retrying with extended timeout", - url - ); - match probe_host_with_timeout(url, STARTUP_REMOTE_PROBE_HARD_TIMEOUT).await { - Ok(hard_probe) if matches!(hard_probe.status.as_str(), "ok" | "auth") => { - (true, Some(hard_probe)) - } - Ok(hard_probe) => (false, Some(hard_probe)), - Err(_) => (false, None), - } - } - }; - - ProbeWithRetryResult { - navigable, - probe: final_probe, - } -} - -/// Determine the boot outcome from the desktop hosts config, optional probe -/// result, local server availability, and optional env-forced URL. -/// -/// When `env_target_url` is `Some`, it overrides the config-based default -/// host selection. The returned outcome always describes the actual boot -/// target, including env-forced remotes. -/// -/// This is the single source of truth for boot resolution logic. Both the -/// initial startup and `open_new_window` should delegate to this function -/// for consistency. -fn resolve_boot_outcome( - cfg: &DesktopHostsConfig, - probe: Option<&HostProbeResult>, - local_available: bool, - env_target_url: Option<&str>, -) -> DesktopBootOutcome { - let probe_class = ProbeClass::from_probe(probe); - - // Env-forced URL takes precedence over config. This is its own - // authoritative branch — never falls through to config-based resolution. - if let Some(env_url) = env_target_url { - return match probe_class { - ProbeClass::Ok | ProbeClass::Auth | ProbeClass::NoProbe => DesktopBootOutcome { - target: Some("remote".to_string()), - status: "ok".to_string(), - host_id: Some(ENV_OVERRIDE_HOST_ID.to_string()), - url: Some(env_url.to_string()), - }, - ProbeClass::WrongService => DesktopBootOutcome { - target: Some("remote".to_string()), - status: "wrong-service".to_string(), - host_id: Some(ENV_OVERRIDE_HOST_ID.to_string()), - url: Some(env_url.to_string()), - }, - ProbeClass::Unreachable => DesktopBootOutcome { - target: Some("remote".to_string()), - status: "unreachable".to_string(), - host_id: Some(ENV_OVERRIDE_HOST_ID.to_string()), - url: Some(env_url.to_string()), - }, - }; - } - - // No default host configured - let default_id = cfg.default_host_id.as_deref().unwrap_or(""); - if default_id.is_empty() { - // Whether or not choice is completed, no default means not-configured - return DesktopBootOutcome { - target: None, - status: "not-configured".to_string(), - host_id: None, - url: None, - }; - } - - // Default is local - if default_id == LOCAL_HOST_ID { - if local_available { - return DesktopBootOutcome { - target: Some("local".to_string()), - status: "ok".to_string(), - host_id: None, - url: None, - }; - } - return DesktopBootOutcome { - target: Some("local".to_string()), - status: "unreachable".to_string(), - host_id: None, - url: None, - }; - } - - // Default is a remote host — find it - let host = cfg - .hosts - .iter() - .find(|h| h.id == default_id); - - let Some(host) = host else { - return DesktopBootOutcome { - target: Some("remote".to_string()), - status: "missing".to_string(), - host_id: Some(default_id.to_string()), - url: None, - }; - }; - - let host_id = host.id.clone(); - let host_url = host.url.clone(); - - match probe_class { - ProbeClass::Ok | ProbeClass::Auth => DesktopBootOutcome { - target: Some("remote".to_string()), - status: "ok".to_string(), - host_id: Some(host_id), - url: Some(host_url), - }, - ProbeClass::WrongService => DesktopBootOutcome { - target: Some("remote".to_string()), - status: "wrong-service".to_string(), - host_id: Some(host_id), - url: Some(host_url), - }, - ProbeClass::Unreachable => DesktopBootOutcome { - target: Some("remote".to_string()), - status: "unreachable".to_string(), - host_id: Some(host_id), - url: Some(host_url), - }, - ProbeClass::NoProbe => { - // No probe result and choice already completed — treat as recovery - // (the probe hasn't happened yet, but the user has made a choice, - // so this shouldn't normally occur in practice). - DesktopBootOutcome { - target: Some("remote".to_string()), - status: "unreachable".to_string(), - host_id: Some(host_id), - url: Some(host_url), - } - } - } -} - -/// Compute the boot outcome to display when the local server fails to start. -/// -/// This ensures the UI leaves the splash screen and shows an appropriate -/// chooser/recovery state instead of hanging. It delegates to the existing -/// `resolve_boot_outcome` with `local_available = false` and no probe. -fn compute_local_startup_failure_boot_outcome(cfg: &DesktopHostsConfig) -> DesktopBootOutcome { - resolve_boot_outcome(cfg, None, false, None) -} - -/// Build the init script for the startup failure fallback case. -/// -/// Uses an empty `local_origin` since the local server is not running; -/// the UI can fall back to `window.location.origin` when needed. -fn build_startup_failure_init_script(boot_outcome: &DesktopBootOutcome) -> String { - build_init_script("", Some(boot_outcome)) -} - -#[tauri::command] -fn desktop_hosts_get() -> Result { - Ok(read_desktop_hosts_config_from_disk()) -} - -#[tauri::command] -fn desktop_hosts_set(input: DesktopHostsConfigInput) -> Result<(), String> { - write_desktop_hosts_config_input_to_path(&settings_file_path(), &input) - .map_err(|err| err.to_string()) -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct HostProbeResult { - status: String, - latency_ms: u64, -} - -async fn probe_host_with_timeout(url: &str, timeout: Duration) -> Result { - let health = build_health_url(url).ok_or_else(|| "Invalid URL".to_string())?; - let client = reqwest::Client::builder() - .no_proxy() - .timeout(timeout) - .build() - .map_err(|err| err.to_string())?; - let started = std::time::Instant::now(); - - match client.get(&health).send().await { - Ok(resp) => { - let status = resp.status(); - let latency_ms = started.elapsed().as_millis() as u64; - if status.is_success() { - Ok(HostProbeResult { - status: "ok".to_string(), - latency_ms, - }) - } else if status.as_u16() == 401 || status.as_u16() == 403 { - Ok(HostProbeResult { - status: "auth".to_string(), - latency_ms, - }) - } else { - Ok(HostProbeResult { - status: "unreachable".to_string(), - latency_ms, - }) - } - } - Err(_) => Ok(HostProbeResult { - status: "unreachable".to_string(), - latency_ms: started.elapsed().as_millis() as u64, - }), - } -} - -async fn wait_for_local_opencode_ready_with( - url: &str, - timeout: Duration, - initial_interval: Duration, - max_interval: Duration, -) -> Option { - let deadline = std::time::Instant::now() + timeout; - let mut interval = initial_interval; - let mut last_probe: Option = None; - - while std::time::Instant::now() < deadline { - match probe_host_with_timeout(url, max_interval).await { - Ok(probe) if matches!(probe.status.as_str(), "ok" | "auth") => { - return Some(probe); - } - Ok(probe) => { - last_probe = Some(probe); - } - Err(_) => {} - } - - tokio::time::sleep(interval).await; - interval = (interval * 2).min(max_interval); - } - - last_probe -} - -#[cfg(target_os = "macos")] -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScheduledTasksQuitRiskResponse { - has_enabled_scheduled_tasks: bool, - has_running_scheduled_tasks: bool, - #[serde(default)] - enabled_scheduled_tasks_count: u32, - #[serde(default)] - running_scheduled_tasks_count: u32, -} - -#[cfg(target_os = "macos")] -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct TunnelStatusResponse { - active: bool, -} - -#[cfg(target_os = "macos")] -async fn refresh_quit_risk_flags(local_base_url: &str) { - use std::sync::atomic::Ordering; - - let trimmed = local_base_url.trim_end_matches('/'); - if trimmed.is_empty() { - return; - } - - let client = match reqwest::Client::builder() - .no_proxy() - .timeout(Duration::from_secs(2)) - .build() - { - Ok(client) => client, - Err(_) => return, - }; - - let scheduled_url = format!("{trimmed}/api/openchamber/scheduled-tasks/status"); - let tunnel_url = format!("{trimmed}/api/openchamber/tunnel/status"); - - let scheduled_future = client.get(scheduled_url).send(); - let tunnel_future = client.get(tunnel_url).send(); - let (scheduled_result, tunnel_result) = tokio::join!(scheduled_future, tunnel_future); - - if let Ok(response) = scheduled_result { - if response.status().is_success() { - if let Ok(payload) = response.json::().await { - let enabled_count = payload.enabled_scheduled_tasks_count; - let running_count = payload.running_scheduled_tasks_count; - QUIT_RISK_ENABLED_SCHEDULED_TASKS_COUNT.store(enabled_count, Ordering::Relaxed); - QUIT_RISK_RUNNING_SCHEDULED_TASKS_COUNT.store(running_count, Ordering::Relaxed); - QUIT_RISK_HAS_ENABLED_SCHEDULED_TASKS - .store(payload.has_enabled_scheduled_tasks || enabled_count > 0, Ordering::Relaxed); - QUIT_RISK_HAS_RUNNING_SCHEDULED_TASKS - .store(payload.has_running_scheduled_tasks || running_count > 0, Ordering::Relaxed); - } - } - } - - if let Ok(response) = tunnel_result { - if response.status().is_success() { - if let Ok(payload) = response.json::().await { - QUIT_RISK_HAS_ACTIVE_TUNNEL.store(payload.active, Ordering::Relaxed); - } - } - } -} - -#[cfg(target_os = "macos")] -fn start_quit_risk_poller(local_base_url: String) { - use std::sync::atomic::Ordering; - - if QUIT_RISK_POLLER_STARTED.swap(true, Ordering::SeqCst) { - return; - } - - tauri::async_runtime::spawn(async move { - loop { - refresh_quit_risk_flags(&local_base_url).await; - tokio::time::sleep(QUIT_RISK_POLL_INTERVAL).await; - } - }); -} - -#[cfg(not(target_os = "macos"))] -fn start_quit_risk_poller(_local_base_url: String) {} - -/// Uses the same probe_with_retry policy as startup/new-window (soft + hard) -/// so that first-launch/recovery remote connect accepts slow-but-valid hosts. -#[tauri::command] -async fn desktop_host_probe(url: String) -> Result { - let result = probe_with_retry(&url).await; - result - .probe - .ok_or_else(|| "Probe failed".to_string()) -} - -#[derive(Clone, Serialize)] -#[serde(tag = "event", content = "data")] -enum UpdateProgressEvent { - #[serde(rename_all = "camelCase")] - Started { - content_length: Option, - }, - #[serde(rename_all = "camelCase")] - Progress { - chunk_length: usize, - downloaded: u64, - total: Option, - }, - Finished, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct DesktopUpdateInfo { - available: bool, - current_version: String, - version: Option, - body: Option, - date: Option, -} - -struct PendingUpdate(Mutex>); - -fn pick_unused_port() -> Result { - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - Ok(port) -} - -fn is_nonempty_string(value: &str) -> bool { - !value.trim().is_empty() -} - -const CHANGELOG_URL: &str = - "https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md"; - -fn parse_semver_num(value: &str) -> Option { - let trimmed = value.trim().trim_start_matches('v'); - let mut parts = trimmed.split('.'); - let major: u32 = parts.next()?.parse().ok()?; - let minor: u32 = parts.next()?.parse().ok()?; - let patch: u32 = parts.next()?.parse().ok()?; - Some(major.saturating_mul(10_000) + minor.saturating_mul(100) + patch) -} - -fn is_placeholder_release_notes(body: &Option) -> bool { - let Some(body) = body.as_ref() else { - return true; - }; - let trimmed = body.trim(); - if trimmed.is_empty() { - return true; - } - trimmed - .to_ascii_lowercase() - .starts_with("see release notes at") -} - -async fn fetch_changelog_notes(from_version: &str, to_version: &str) -> Option { - let from_num = parse_semver_num(from_version)?; - let to_num = parse_semver_num(to_version)?; - if to_num <= from_num { - return None; - } - - let client = reqwest::Client::builder() - .no_proxy() - .timeout(Duration::from_secs(10)) - .build() - .ok()?; - - let response = client.get(CHANGELOG_URL).send().await.ok()?; - if !response.status().is_success() { - return None; - } - let changelog = response.text().await.ok()?; - if changelog.trim().is_empty() { - return None; - } - - let mut markers: Vec<(usize, Option)> = Vec::new(); - let mut offset: usize = 0; - for line in changelog.lines() { - let line_trimmed = line.trim_end_matches('\r'); - if line_trimmed.starts_with("## [") { - let ver = line_trimmed - .strip_prefix("## [") - .and_then(|rest| rest.split(']').next()) - .unwrap_or(""); - markers.push((offset, parse_semver_num(ver))); - } - offset = offset.saturating_add(line.len().saturating_add(1)); - } - - if markers.is_empty() { - return None; - } - - let mut relevant: Vec = Vec::new(); - for idx in 0..markers.len() { - let (start, ver_num) = markers[idx]; - let end = markers - .get(idx + 1) - .map(|m| m.0) - .unwrap_or_else(|| changelog.len()); - let Some(ver_num) = ver_num else { - continue; - }; - if ver_num <= from_num || ver_num > to_num { - continue; - } - if start >= changelog.len() || end <= start { - continue; - } - let end_clamped = end.min(changelog.len()); - let section = changelog[start..end_clamped].trim(); - if !section.is_empty() { - relevant.push(section.to_string()); - } - } - - if relevant.is_empty() { - None - } else { - Some(relevant.join("\n\n")) - } -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SidecarNotifyPayload { - title: Option, - body: Option, - tag: Option, - require_hidden: Option, -} - -fn maybe_show_sidecar_notification(app: &tauri::AppHandle, payload: SidecarNotifyPayload) { - let require_hidden = payload.require_hidden.unwrap_or(false); - if require_hidden { - let any_focused = app - .try_state::() - .map(|state| state.any_focused()) - .unwrap_or(false); - if any_focused { - return; - } - } - - let title = payload - .title - .filter(|t| is_nonempty_string(t)) - .unwrap_or_else(|| "OpenChamber".to_string()); - let body = payload.body.filter(|b| is_nonempty_string(b)); - let _tag = payload.tag; - - use tauri_plugin_notification::NotificationExt; - - let mut builder = app.notification().builder().title(title); - if let Some(body) = body { - builder = builder.body(body); - } - - #[cfg(target_os = "macos")] - { - builder = builder.sound("Glass"); - } - let _ = builder.show(); -} - -async fn wait_for_health_with( - url: &str, - timeout: Duration, - initial_interval: Duration, - max_interval: Duration, -) -> bool { - let client = match reqwest::Client::builder().no_proxy().build() { - Ok(c) => c, - Err(_) => return false, - }; - - let deadline = std::time::Instant::now() + timeout; - let health_url = format!("{}/health", url.trim_end_matches('/')); - let mut interval = initial_interval; - - while std::time::Instant::now() < deadline { - if let Ok(resp) = client.get(&health_url).send().await { - if resp.status().is_success() { - return true; - } - } - tokio::time::sleep(interval).await; - interval = (interval * 2).min(max_interval); - } - - false -} - -async fn wait_for_health(url: &str) -> bool { - wait_for_health_with(url, HEALTH_TIMEOUT, HEALTH_POLL_INITIAL_INTERVAL, HEALTH_POLL_MAX_INTERVAL).await -} - -fn kill_sidecar(app: tauri::AppHandle) { - let Some(state) = app.try_state::() else { - return; - }; - - let sidecar_url = state.url.lock().expect("sidecar url mutex").clone(); - if let Some(url) = sidecar_url { - // Attempt graceful shutdown via a raw HTTP POST to avoid pulling in - // reqwest::blocking (and its extra thread pool) just for this one call. - if let Ok(parsed) = url::Url::parse(&url) { - let host = parsed.host_str().unwrap_or("127.0.0.1"); - let port = parsed.port().unwrap_or(80); - let path = "/api/system/shutdown"; - if let Ok(mut stream) = - std::net::TcpStream::connect_timeout( - &format!("{host}:{port}").parse().unwrap(), - Duration::from_millis(1500), - ) - { - use std::io::Write; - let _ = stream.set_write_timeout(Some(Duration::from_millis(1500))); - let _ = stream.set_read_timeout(Some(Duration::from_millis(1500))); - let request = format!( - "POST {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - ); - let _ = stream.write_all(request.as_bytes()); - let _ = stream.flush(); - // Brief pause to let the sidecar begin its shutdown sequence. - std::thread::sleep(Duration::from_millis(100)); - } - } - } - - let mut guard = state.child.lock().expect("sidecar mutex"); - if let Some(child) = guard.take() { - let _ = child.kill(); - } - *state.url.lock().expect("sidecar url mutex") = None; -} - -fn build_local_url(port: u16) -> String { - format!("http://127.0.0.1:{port}") -} - -/// Kills any stale openchamber-server processes that may be lingering from -/// previous app sessions or incomplete shutdowns. This ensures a clean -/// startup and prevents port conflicts. -fn kill_stale_sidecar_processes() { - let process_name = if cfg!(windows) { - "openchamber-server.exe" - } else { - "openchamber-server" - }; - - let result = if cfg!(target_os = "macos") { - // macOS: use pkill to terminate by process name - std::process::Command::new("pkill") - .arg("-x") // exact match - .arg(process_name) - .output() - } else if cfg!(target_os = "linux") { - // Linux: use pkill - std::process::Command::new("pkill") - .arg("-x") - .arg(process_name) - .output() - } else if cfg!(windows) { - // Windows: use taskkill - std::process::Command::new("taskkill") - .arg("/F") - .arg("/IM") - .arg(process_name) - .output() - } else { - return; - }; - - // Log result for debugging (pkill returns 1 if no processes found, which is fine) - if let Ok(output) = result { - log::debug!( - "[sidecar] cleanup result: exit_code={:?}, stdout={}, stderr={}", - output.status.code(), - String::from_utf8_lossy(&output.stdout).trim(), - String::from_utf8_lossy(&output.stderr).trim() - ); - } - - // Brief pause to let the OS clean up the processes - std::thread::sleep(Duration::from_millis(100)); -} - -async fn spawn_local_server(app: &tauri::AppHandle) -> Result { - // Clean up any stale sidecar processes from previous sessions - kill_stale_sidecar_processes(); - - let stored_port = read_desktop_local_port_from_disk(); - let mut candidates: Vec> = Vec::new(); - if let Some(port) = stored_port { - candidates.push(Some(port)); - } - candidates.push(Some(DEFAULT_DESKTOP_PORT)); - candidates.push(None); - - let dist_dir = resolve_web_dist_dir(app)?; - let no_proxy = "localhost,127.0.0.1"; - - // macOS app launch env often lacks user PATH entries. - let mut path_segments: Vec = Vec::new(); - let mut seen = std::collections::HashSet::::new(); - - let resolved_home_dir_path = app.path().home_dir().ok(); - let resolved_home_dir = resolved_home_dir_path.as_ref().and_then(|p| { - let s = p.to_string_lossy().to_string(); - if s.trim().is_empty() { - None - } else { - Some(s) - } - }); - - let desktop_settings = read_desktop_settings_json(); - - let opencode_binary_from_settings: Option = (|| { - let value = desktop_settings.as_ref()?.get("opencodeBinary")?.as_str()?.trim(); - if value.is_empty() { - return None; - } - - let mut candidate = value.to_string(); - if fs::metadata(&candidate) - .map(|m| m.is_dir()) - .unwrap_or(false) - { - let bin_name = if cfg!(windows) { - "opencode.exe" - } else { - "opencode" - }; - candidate = PathBuf::from(candidate) - .join(bin_name) - .to_string_lossy() - .to_string(); - } - - Some(candidate) - })(); - - let sidecar_bind_host = desktop_settings - .as_ref() - .and_then(|value| value.get("desktopLanAccessEnabled")) - .and_then(|value| value.as_bool()) - .map(|enabled| if enabled { "0.0.0.0" } else { "127.0.0.1" }) - .unwrap_or("127.0.0.1"); - - let mut push_unique = |value: String| { - let trimmed = value.trim(); - if trimmed.is_empty() { - return; - } - if seen.insert(trimmed.to_string()) { - path_segments.push(trimmed.to_string()); - } - }; - - // Respect explicit binary overrides by adding their parent dir first. - if let Some(val) = opencode_binary_from_settings.as_deref() { - let trimmed = val.trim(); - if !trimmed.is_empty() { - let path = std::path::Path::new(trimmed); - if let Some(parent) = path.parent() { - push_unique(parent.to_string_lossy().to_string()); - } - } - } - - for var in [ - "OPENCHAMBER_OPENCODE_PATH", - "OPENCHAMBER_OPENCODE_BIN", - "OPENCODE_PATH", - "OPENCODE_BINARY", - ] { - if let Ok(val) = env::var(var) { - let trimmed = val.trim(); - if trimmed.is_empty() { - continue; - } - let path = std::path::Path::new(trimmed); - if let Some(parent) = path.parent() { - push_unique(parent.to_string_lossy().to_string()); - } - } - } - - // Common locations. - push_unique("/opt/homebrew/bin".to_string()); - push_unique("/usr/local/bin".to_string()); - push_unique("/usr/bin".to_string()); - push_unique("/bin".to_string()); - push_unique("/usr/sbin".to_string()); - push_unique("/sbin".to_string()); - - if let Some(home) = resolved_home_dir.as_deref() { - // OpenCode installer default. - push_unique(format!("{home}/.opencode/bin")); - push_unique(format!("{home}/.local/bin")); - push_unique(format!("{home}/.bun/bin")); - push_unique(format!("{home}/.cargo/bin")); - push_unique(format!("{home}/bin")); - } - - if let Ok(existing) = env::var("PATH") { - for segment in existing.split(':') { - push_unique(segment.to_string()); - } - } - - let augmented_path = path_segments.join(":"); - - for candidate in candidates { - let port = match candidate { - Some(p) => p, - None => pick_unused_port()?, - }; - let url = build_local_url(port); - - let mut cmd = app - .shell() - .sidecar(SIDECAR_NAME) - .map_err(|err| anyhow!("Failed to resolve sidecar '{SIDECAR_NAME}': {err}"))? - .args(["--port", &port.to_string()]) - .env("OPENCHAMBER_HOST", sidecar_bind_host) - .env("OPENCHAMBER_DIST_DIR", dist_dir.clone()) - .env("OPENCHAMBER_RUNTIME", "desktop") - .env("OPENCHAMBER_DESKTOP_NOTIFY", "true") - .env("OPENCHAMBER_SKIP_API_COMPRESSION", "true") - .env("PATH", augmented_path.clone()) - .env("NO_PROXY", no_proxy) - .env("no_proxy", no_proxy); - - if let Some(home) = resolved_home_dir.as_deref() { - cmd = cmd.env("HOME", home); - } - - if let Some(bin) = opencode_binary_from_settings.as_deref() { - let trimmed = bin.trim(); - if !trimmed.is_empty() { - cmd = cmd.env("OPENCODE_BINARY", trimmed); - } - } - - if let Ok(password) = env::var("OPENCODE_SERVER_PASSWORD") { - let trimmed = password.trim(); - if !trimmed.is_empty() { - cmd = cmd.env("OPENCODE_SERVER_PASSWORD", trimmed); - } - } - - let (rx, child) = match cmd.spawn() { - Ok(v) => v, - Err(err) => { - log::warn!("[sidecar] spawn failed on port {port}: {err}"); - continue; - } - }; - - let app_handle = app.clone(); - tauri::async_runtime::spawn(async move { - let mut rx = rx; - while let Some(event) = rx.recv().await { - match event { - CommandEvent::Stdout(bytes) => { - let line = String::from_utf8_lossy(&bytes); - if let Some(rest) = line.strip_prefix(SIDECAR_NOTIFY_PREFIX) { - if let Ok(parsed) = - serde_json::from_str::(rest.trim()) - { - maybe_show_sidecar_notification(&app_handle, parsed); - } - } - } - CommandEvent::Error(error) => { - log::warn!("[sidecar] error: {error}"); - } - CommandEvent::Terminated(payload) => { - log::warn!( - "[sidecar] terminated code={:?} signal={:?}", - payload.code, - payload.signal - ); - break; - } - _ => {} - } - } - }); - - if let Some(state) = app.try_state::() { - *state.child.lock().expect("sidecar mutex") = Some(child); - *state.url.lock().expect("sidecar url mutex") = Some(url.clone()); - } - - if !wait_for_health_with( - &url, - LOCAL_SIDECAR_HEALTH_TIMEOUT, - LOCAL_SIDECAR_HEALTH_POLL_INITIAL_INTERVAL, - LOCAL_SIDECAR_HEALTH_POLL_MAX_INTERVAL, - ) - .await - { - kill_sidecar(app.clone()); - continue; - } - - let _ = write_desktop_local_port_to_disk(port); - return Ok(url); - } - - Err(anyhow!("Sidecar health check failed")) -} - -fn resolve_web_dist_dir(app: &tauri::AppHandle) -> Result { - let candidates = ["web-dist", "resources/web-dist"]; - for candidate in candidates { - let path = app - .path() - .resolve(candidate, tauri::path::BaseDirectory::Resource) - .map_err(|err| anyhow!("Failed to resolve '{candidate}' resources: {err}"))?; - let index = path.join("index.html"); - if fs::metadata(&index).is_ok() { - return Ok(path); - } - } - - Err(anyhow!( - "Web assets missing in app resources (expected index.html under web-dist)" - )) -} - -fn normalize_server_url(input: &str) -> Option { - normalize_host_url(input) -} - -#[derive(Deserialize)] -struct DesktopNotifyPayload { - title: Option, - body: Option, - tag: Option, -} - -#[tauri::command] -fn desktop_notify( - app: tauri::AppHandle, - payload: Option, -) -> Result { - let payload = payload.unwrap_or(DesktopNotifyPayload { - title: None, - body: None, - tag: None, - }); - - use tauri_plugin_notification::NotificationExt; - - let mut builder = app - .notification() - .builder() - .title(payload.title.unwrap_or_else(|| "OpenChamber".to_string())); - - if let Some(body) = payload.body { - if is_nonempty_string(&body) { - builder = builder.body(body); - } - } - - if let Some(tag) = payload.tag { - if is_nonempty_string(&tag) { - let _ = tag; - } - } - - #[cfg(target_os = "macos")] - { - builder = builder.sound("Glass"); - } - - builder.show().map(|_| true).map_err(|err| err.to_string()) -} - -#[tauri::command] -async fn desktop_check_for_updates( - app: tauri::AppHandle, - pending: tauri::State<'_, PendingUpdate>, -) -> Result { - let updater = app.updater().map_err(|err| err.to_string())?; - let update = updater.check().await.map_err(|err| err.to_string())?; - - let current_version = app.package_info().version.to_string(); - - let info = if let Some(update) = update { - *pending.0.lock().expect("pending update mutex") = Some(update.clone()); - let mut body = update.body.clone(); - if is_placeholder_release_notes(&body) { - if let Some(notes) = fetch_changelog_notes(¤t_version, &update.version).await { - body = Some(notes); - } - } - DesktopUpdateInfo { - available: true, - current_version, - version: Some(update.version.clone()), - body, - date: update.date.map(|date| date.to_string()), - } - } else { - *pending.0.lock().expect("pending update mutex") = None; - DesktopUpdateInfo { - available: false, - current_version, - version: None, - body: None, - date: None, - } - }; - - Ok(info) -} - -#[tauri::command] -async fn desktop_download_and_install_update( - app: tauri::AppHandle, - pending: tauri::State<'_, PendingUpdate>, -) -> Result<(), String> { - let Some(update) = pending.0.lock().expect("pending update mutex").take() else { - return Err("No pending update".to_string()); - }; - - let mut downloaded: u64 = 0; - let mut total: Option = None; - let mut started = false; - - update - .download_and_install( - |chunk_length, content_length| { - if !started { - total = content_length; - let _ = app.emit( - "openchamber:update-progress", - UpdateProgressEvent::Started { content_length }, - ); - started = true; - } - - downloaded = downloaded.saturating_add(chunk_length as u64); - let _ = app.emit( - "openchamber:update-progress", - UpdateProgressEvent::Progress { - chunk_length, - downloaded, - total, - }, - ); - }, - || { - let _ = app.emit("openchamber:update-progress", UpdateProgressEvent::Finished); - }, - ) - .await - .map_err(|err| err.to_string())?; - - Ok(()) -} - -#[tauri::command] -fn desktop_restart(app: tauri::AppHandle) { - app.restart(); -} - -/// Create a new desktop window from the UI layer. -/// -/// IMPORTANT: This command MUST remain synchronous (not `async`). Tauri runs -/// sync commands on the main thread, which is required on macOS for -/// `WebviewWindowBuilder::build()`. Making this `async` would move execution -/// to the Tokio thread pool and risk crashes or undefined behavior. -#[tauri::command] -fn desktop_new_window(app: tauri::AppHandle) -> Result<(), String> { - open_new_window(&app); - Ok(()) -} - -/// Open a new window pointed at a specific URL (used by the host switcher UI). -/// -/// For remote URLs (not matching local origin), probes the host and only opens -/// the window if the probe returns `ok` or `auth`. Falls back to local if the -/// remote is non-navigable. Window creation is dispatched to the main thread -/// and its result is propagated back to the caller. -#[tauri::command] -async fn desktop_new_window_at_url(app: tauri::AppHandle, url: String) -> Result<(), String> { - // Validate scheme to prevent file://, data:, javascript: etc. - let parsed = url::Url::parse(&url).map_err(|e| format!("Invalid URL: {e}"))?; - match parsed.scheme() { - "http" | "https" => {} - scheme => return Err(format!("Unsupported URL scheme: {scheme}")), - } - - let local_origin = app - .try_state::() - .and_then(|state| { - state - .local_origin - .lock() - .expect("desktop local origin mutex") - .clone() - }) - .ok_or_else(|| "Local origin not yet known (sidecar may still be starting)".to_string())?; - - // If the URL is local, create the window directly. - if same_server_url(&url, &local_origin) { - let boot_outcome = DesktopBootOutcome { - target: Some("local".to_string()), - status: "ok".to_string(), - host_id: None, - url: None, - }; - let (tx, rx) = tokio::sync::oneshot::channel(); - let handle = app.clone(); - app.run_on_main_thread(move || { - let result = create_window(&handle, &url, &local_origin, Some(&boot_outcome), false) - .map_err(|e| e.to_string()); - let _ = tx.send(result); - }) - .map_err(|e| e.to_string())?; - return rx.await.map_err(|_| "Window creation cancelled".to_string())?; - } - - // Remote URL: probe with shared retry policy before opening. - let result = probe_with_retry(&url).await; - - let (final_url, boot_outcome) = if result.navigable { - let outcome = DesktopBootOutcome { - target: Some("remote".to_string()), - status: "ok".to_string(), - host_id: Some(DIRECT_URL_HOST_ID.to_string()), - url: Some(url.clone()), - }; - (url, outcome) - } else { - log::info!( - "[desktop] new_window_at_url: remote ({}) probe returned non-navigable status, falling back to local", - url - ); - let local_fallback = format!("{}/", local_origin); - let outcome = match &result.probe { - Some(probe) if probe.status == "wrong-service" => DesktopBootOutcome { - target: Some("remote".to_string()), - status: "wrong-service".to_string(), - host_id: Some(DIRECT_URL_HOST_ID.to_string()), - url: Some(url), - }, - _ => DesktopBootOutcome { - target: Some("remote".to_string()), - status: "unreachable".to_string(), - host_id: Some(DIRECT_URL_HOST_ID.to_string()), - url: Some(url), - }, - }; - (local_fallback, outcome) - }; - - let (tx, rx) = tokio::sync::oneshot::channel(); - let handle = app.clone(); - app.run_on_main_thread(move || { - let result = create_window(&handle, &final_url, &local_origin, Some(&boot_outcome), false) - .map_err(|e| e.to_string()); - let _ = tx.send(result); - }) - .map_err(|e| e.to_string())?; - rx.await.map_err(|_| "Window creation cancelled".to_string())? -} - -/// Read a file and return its content as base64 with mime type detection. -/// Used for drag-drop file attachments in desktop app. -#[tauri::command] -fn desktop_read_file(path: String) -> Result { - use std::path::Path; - - let path = Path::new(&path); - - // Check file size (max 50MB) - let metadata = - std::fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {e}"))?; - let size = metadata.len(); - if size > 50 * 1024 * 1024 { - return Err("File is too large. Maximum size is 50MB.".to_string()); - } - - // Read file bytes - let bytes = std::fs::read(path).map_err(|e| format!("Failed to read file: {e}"))?; - - // Detect mime type from extension - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - let mime = match ext.as_str() { - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "svg" => "image/svg+xml", - "bmp" => "image/bmp", - "ico" => "image/x-icon", - "pdf" => "application/pdf", - "txt" => "text/plain", - "md" => "text/markdown", - "json" => "application/json", - "js" => "text/javascript", - "ts" => "text/typescript", - "tsx" => "text/typescript-jsx", - "jsx" => "text/javascript-jsx", - "html" => "text/html", - "css" => "text/css", - "py" => "text/x-python", - _ => "application/octet-stream", - }; - - // Encode as base64 - let base64 = general_purpose::STANDARD.encode(&bytes); - - Ok(FileContent { - mime: mime.to_string(), - base64, - size: bytes.len(), - }) -} - -#[tauri::command] -async fn desktop_save_markdown_file( - app: tauri::AppHandle, - default_file_name: String, - content: String, -) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - - let trimmed_file_name = default_file_name.trim(); - if trimmed_file_name.is_empty() { - return Err("Default file name is required".to_string()); - } - - let (tx, rx) = tokio::sync::oneshot::channel(); - app.dialog() - .file() - .add_filter("Markdown", &["md"]) - .set_file_name(trimmed_file_name) - .save_file(move |file_path| { - let _ = tx.send(file_path); - }); - - let Some(file_path) = rx - .await - .map_err(|_| "Save dialog was closed unexpectedly".to_string())? - else { - return Ok(None); - }; - - let path = file_path - .into_path() - .map_err(|_| "Selected export path is not a local filesystem path".to_string())?; - - std::fs::write(&path, content) - .map_err(|error| format!("Failed to save exported session: {error}"))?; - - Ok(Some(path.to_string_lossy().to_string())) -} - -#[derive(Serialize)] -struct FileContent { - mime: String, - base64: String, - size: usize, -} - -#[cfg(target_os = "macos")] -fn macos_major_version() -> Option { - fn cmd_stdout(cmd: &str, args: &[&str]) -> Option { - let output = Command::new(cmd).args(args).output().ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() - } - - // Use marketing version (sw_vers), but map legacy 10.x to minor (10.15 -> 15). - // This matches WebKit UA fallback logic in the UI. - if let Some(raw) = cmd_stdout("/usr/bin/sw_vers", &["-productVersion"]) - .or_else(|| cmd_stdout("sw_vers", &["-productVersion"])) - { - let raw = raw.trim(); - let mut parts = raw.split('.'); - let major = parts.next().and_then(|v| v.parse::().ok())?; - let minor = parts - .next() - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - return Some(if major == 10 { minor } else { major }); - } - - // Fallback: derive from Darwin major (kern.osrelease major). - let raw = cmd_stdout("/usr/sbin/sysctl", &["-n", "kern.osrelease"]) - .or_else(|| cmd_stdout("sysctl", &["-n", "kern.osrelease"])) - .or_else(|| cmd_stdout("/usr/bin/uname", &["-r"])) - .or_else(|| cmd_stdout("uname", &["-r"]))?; - let raw = raw.trim(); - let major = raw.split('.').next()?.parse::().ok()?; - if major >= 20 { - return Some(major - 9); - } - if major >= 15 { - return Some(major - 4); - } - Some(major) -} - -#[cfg(not(target_os = "macos"))] -fn macos_major_version() -> Option { - None -} - -/// Build the initialization script injected into every webview window. -/// This is computed once and reused for all windows. -fn build_init_script(local_origin: &str, boot_outcome: Option<&DesktopBootOutcome>) -> String { - let home = - std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_default(); - let macos_major = macos_major_version().unwrap_or(0); - - let home_json = serde_json::to_string(&home).unwrap_or_else(|_| "\"\"".into()); - let local_json = serde_json::to_string(local_origin).unwrap_or_else(|_| "\"\"".into()); - let boot_outcome_json = boot_outcome - .and_then(|o| serde_json::to_string(o).ok()) - .unwrap_or_else(|| "undefined".to_string()); - - let mut init_script = format!( - "(function(){{try{{window.__OPENCHAMBER_HOME__={home_json};window.__OPENCHAMBER_MACOS_MAJOR__={macos_major};window.__OPENCHAMBER_LOCAL_ORIGIN__={local_json};window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__={boot_outcome_json};}}catch(_e){{}}}})();" - ); - - // Cleanup: older builds injected a native-ish Instance switcher button into pages. - // Remove it if present so the UI-owned host switcher is the only one. - init_script.push_str("\ntry{var old=document.getElementById('__oc-instance-switcher');if(old)old.remove();}catch(_e){}"); - - if !cfg!(debug_assertions) { - init_script.push_str("\ntry{document.addEventListener('contextmenu',function(e){var t=e&&e.target;if(!t||typeof t.closest!=='function'){e.preventDefault();return;}if(t.closest('.terminal-viewport-container,[data-oc-allow-native-contextmenu],a,input,textarea,[contenteditable=\"true\"]')){return;}e.preventDefault();},true);}catch(_e){}"); - } - - init_script -} - -fn parse_theme_override(theme_mode: Option<&str>, theme_variant: Option<&str>) -> Option { - match theme_mode.map(str::trim) { - Some("system") => None, - Some("dark") => Some(tauri::Theme::Dark), - Some("light") => Some(tauri::Theme::Light), - _ => match theme_variant.map(str::trim) { - Some("dark") => Some(tauri::Theme::Dark), - Some("light") => Some(tauri::Theme::Light), - _ => None, - }, - } -} - -fn read_desktop_theme_override() -> Option { - let settings = read_desktop_settings_json(); - - let use_system_theme = settings - .as_ref() - .and_then(|value| value.get("useSystemTheme")) - .and_then(|value| value.as_bool()); - - if matches!(use_system_theme, Some(true)) { - return None; - } - - let theme_mode = settings - .as_ref() - .and_then(|value| value.get("themeMode")) - .and_then(|value| value.as_str()); - - let theme_variant = settings - .as_ref() - .and_then(|value| value.get("themeVariant")) - .and_then(|value| value.as_str()); - - parse_theme_override(theme_mode, theme_variant) -} - -fn detect_desktop_lan_ipv4() -> Option { - let socket = UdpSocket::bind("0.0.0.0:0").ok()?; - socket.connect("8.8.8.8:80").ok()?; - let address = socket.local_addr().ok()?; - let ip = address.ip(); - - if ip.is_loopback() { - return None; - } - - match ip { - std::net::IpAddr::V4(ipv4) => Some(ipv4.to_string()), - std::net::IpAddr::V6(_) => None, - } -} - -/// Apply platform-specific window builder configuration. -fn apply_platform_window_config>( - builder: WebviewWindowBuilder<'_, tauri::Wry, M>, -) -> WebviewWindowBuilder<'_, tauri::Wry, M> { - #[cfg(target_os = "macos")] - let builder = builder - .hidden_title(true) - .title_bar_style(tauri::TitleBarStyle::Overlay) - .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { - x: 17.0, - y: 26.0, - })); - - #[cfg(target_os = "windows")] - let builder = builder.additional_browser_args( - "--proxy-bypass-list=<-loopback> --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection", - ); - - builder -} - -#[tauri::command] -fn desktop_set_window_theme( - window: tauri::WebviewWindow, - theme_mode: Option, - theme_variant: Option, -) -> Result<(), String> { - let override_theme = parse_theme_override(theme_mode.as_deref(), theme_variant.as_deref()); - - window - .set_theme(override_theme) - .map_err(|error| format!("failed to set window theme: {error}"))?; - - Ok(()) -} - -#[tauri::command] -fn desktop_get_lan_address() -> Option { - detect_desktop_lan_ipv4() -} - -fn is_window_state_visible(app: &tauri::AppHandle, state: &DesktopWindowState) -> bool { - if state.width == 0 || state.height == 0 { - return false; - } - - let Ok(monitors) = app.available_monitors() else { - return true; - }; - if monitors.is_empty() { - return true; - } - - let left = state.x as f64; - let top = state.y as f64; - let right = left + state.width as f64; - let bottom = top + state.height as f64; - - for monitor in monitors { - let scale = monitor.scale_factor(); - if !scale.is_finite() || scale <= 0.0 { - continue; - } - - let position = monitor.position(); - let size = monitor.size(); - - let monitor_left = position.x as f64 / scale; - let monitor_top = position.y as f64 / scale; - let monitor_right = monitor_left + size.width as f64 / scale; - let monitor_bottom = monitor_top + size.height as f64 / scale; - - let overlap_width = right.min(monitor_right) - left.max(monitor_left); - let overlap_height = bottom.min(monitor_bottom) - top.max(monitor_top); - if overlap_width > 0.0 && overlap_height > 0.0 { - return true; - } - } - - false -} - -fn capture_window_state(window: &tauri::Window) -> Option { - let position = window.outer_position().ok()?; - let size = window.inner_size().ok()?; - let scale = window - .scale_factor() - .ok() - .filter(|value| value.is_finite() && *value > 0.0) - .unwrap_or(1.0); - - Some(DesktopWindowState { - x: (position.x as f64 / scale).round() as i32, - y: (position.y as f64 / scale).round() as i32, - width: (size.width as f64 / scale) - .round() - .max(MIN_WINDOW_WIDTH as f64) as u32, - height: (size.height as f64 / scale) - .round() - .max(MIN_WINDOW_HEIGHT as f64) as u32, - maximized: window.is_maximized().unwrap_or(false), - fullscreen: window.is_fullscreen().unwrap_or(false), - }) -} - -fn schedule_window_state_persist(window: tauri::Window, immediate: bool) { - if window.label() != "main" { - return; - } - - let app = window.app_handle().clone(); - let label = window.label().to_string(); - let revision = { - let Some(state) = app.try_state::() else { - return; - }; - let mut guard = state - .revisions - .lock() - .expect("window geometry debounce mutex"); - let next = guard.get(&label).copied().unwrap_or(0).saturating_add(1); - guard.insert(label.clone(), next); - next - }; - - tauri::async_runtime::spawn(async move { - if !immediate { - tokio::time::sleep(Duration::from_millis(WINDOW_STATE_DEBOUNCE_MS)).await; - } - - let is_latest = app - .try_state::() - .map(|state| { - state - .revisions - .lock() - .map(|guard| guard.get(&label).copied() == Some(revision)) - .unwrap_or(false) - }) - .unwrap_or(false); - if !is_latest { - return; - } - - let Some(snapshot) = capture_window_state(&window) else { - return; - }; - - if let Err(err) = write_desktop_window_state_to_disk(&snapshot) { - log::warn!("[desktop] failed to persist window geometry: {err}"); - } - }); -} - -/// Create a new window with a unique label, pointing at the given URL. -fn create_window( - app: &tauri::AppHandle, - url: &str, - local_origin: &str, - boot_outcome: Option<&DesktopBootOutcome>, - restore_geometry: bool, -) -> Result<()> { - let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?; - let label = next_window_label(app); - - let init_script = build_init_script(local_origin, boot_outcome); - - // Store the init script under this window's label so page reloads - // re-inject the correct boot outcome for this window. - if let Some(state) = app.try_state::() { - state - .scripts - .lock() - .expect("desktop ui injection mutex") - .insert(label.clone(), init_script.clone()); - *state - .local_origin - .lock() - .expect("desktop local origin mutex") = Some(local_origin.to_string()); - } - - let restored_state = if restore_geometry { - read_desktop_window_state_from_disk() - } else { - None - }; - - let mut builder = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed)) - .title("OpenChamber") - .inner_size(1280.0, 800.0) - .min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64) - .decorations(true) - .visible(false) - .initialization_script(&init_script); - - builder = apply_platform_window_config(builder); - - let apply_restored_state = restored_state - .as_ref() - .map(|state| is_window_state_visible(app, state)) - .unwrap_or(false); - - if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { - let restored_width = state.width.max(MIN_RESTORE_WINDOW_WIDTH); - let restored_height = state.height.max(MIN_RESTORE_WINDOW_HEIGHT); - builder = builder - .inner_size(restored_width as f64, restored_height as f64) - .position(state.x as f64, state.y as f64); - } - - let window = builder.build()?; - let _ = window.set_theme(read_desktop_theme_override()); - disable_pinch_zoom(&window); - - if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { - if state.maximized || state.fullscreen { - let _ = window.maximize(); - } - } - - let _ = window.show(); - let _ = window.set_focus(); - - Ok(()) -} - -fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Result<()> { - if app.get_webview_window("main").is_some() { - return Ok(()); - } - - let restored_state = if restore_geometry { - read_desktop_window_state_from_disk() - } else { - None - }; - - let splash_script = build_startup_splash_script(); - - let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) - .title("OpenChamber") - .inner_size(1280.0, 800.0) - .min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64) - .decorations(true) - .visible(true) - .initialization_script(&splash_script); - - builder = apply_platform_window_config(builder); - - let apply_restored_state = restored_state - .as_ref() - .map(|state| is_window_state_visible(app, state)) - .unwrap_or(false); - - if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { - let restored_width = state.width.max(MIN_RESTORE_WINDOW_WIDTH); - let restored_height = state.height.max(MIN_RESTORE_WINDOW_HEIGHT); - builder = builder - .inner_size(restored_width as f64, restored_height as f64) - .position(state.x as f64, state.y as f64); - } - - let window = builder.build()?; - let _ = window.set_theme(read_desktop_theme_override()); - disable_pinch_zoom(&window); - - if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { - if state.maximized || state.fullscreen { - let _ = window.maximize(); - } - } - - let _ = window.show(); - let _ = window.set_focus(); - - Ok(()) -} - -fn build_startup_splash_script() -> String { - let settings = fs::read_to_string(settings_file_path()) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()); - - let theme_mode = settings - .as_ref() - .and_then(|value| value.get("themeMode")) - .and_then(|value| value.as_str()) - .and_then(|value| match value.trim() { - "light" => Some("light"), - "dark" => Some("dark"), - "system" => Some("system"), - _ => None, - }); - - let use_system_theme = settings - .as_ref() - .and_then(|value| value.get("useSystemTheme")) - .and_then(|value| value.as_bool()) - .unwrap_or(true); - - let theme_variant = settings - .as_ref() - .and_then(|value| value.get("themeVariant")) - .and_then(|value| value.as_str()) - .and_then(|value| match value.trim() { - "light" => Some("light"), - "dark" => Some("dark"), - _ => None, - }); - - let effective_mode = theme_mode - .or_else(|| { - if use_system_theme { - Some("system") - } else { - None - } - }) - .or(theme_variant) - .unwrap_or("system"); - - let splash_bg_light = settings - .as_ref() - .and_then(|value| value.get("splashBgLight")) - .and_then(|value| value.as_str()) - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .unwrap_or(""); - let splash_fg_light = settings - .as_ref() - .and_then(|value| value.get("splashFgLight")) - .and_then(|value| value.as_str()) - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .unwrap_or(""); - let splash_bg_dark = settings - .as_ref() - .and_then(|value| value.get("splashBgDark")) - .and_then(|value| value.as_str()) - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .unwrap_or(""); - let splash_fg_dark = settings - .as_ref() - .and_then(|value| value.get("splashFgDark")) - .and_then(|value| value.as_str()) - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .unwrap_or(""); - - let mode_json = serde_json::to_string(effective_mode).unwrap_or_else(|_| "\"system\"".into()); - let bg_light_json = serde_json::to_string(splash_bg_light).unwrap_or_else(|_| "\"\"".into()); - let fg_light_json = serde_json::to_string(splash_fg_light).unwrap_or_else(|_| "\"\"".into()); - let bg_dark_json = serde_json::to_string(splash_bg_dark).unwrap_or_else(|_| "\"\"".into()); - let fg_dark_json = serde_json::to_string(splash_fg_dark).unwrap_or_else(|_| "\"\"".into()); - - format!( - "(function(){{try{{var mode={mode_json};var bgLight={bg_light_json};var fgLight={fg_light_json};var bgDark={bg_dark_json};var fgDark={fg_dark_json};var root=document.documentElement;if(bgLight)root.style.setProperty('--splash-background-light',bgLight);if(fgLight)root.style.setProperty('--splash-stroke-light',fgLight);if(bgDark)root.style.setProperty('--splash-background-dark',bgDark);if(fgDark)root.style.setProperty('--splash-stroke-dark',fgDark);var prefersDark=false;try{{prefersDark=!!(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches);}}catch(_e){{}}var dark=mode==='dark'?true:(mode==='light'?false:prefersDark);root.setAttribute('data-splash-variant',dark?'dark':'light');root.style.setProperty('color-scheme',dark?'dark':'light');}}catch(_e){{}}}})();" - ) -} - -fn activate_main_window( - app: &tauri::AppHandle, - url: &str, - local_origin: &str, - boot_outcome: Option<&DesktopBootOutcome>, -) -> Result<()> { - let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?; - let init_script = build_init_script(local_origin, boot_outcome); - - if let Some(state) = app.try_state::() { - state - .scripts - .lock() - .expect("desktop ui injection mutex") - .insert("main".to_string(), init_script); - *state - .local_origin - .lock() - .expect("desktop local origin mutex") = Some(local_origin.to_string()); - } - - if let Some(window) = app.get_webview_window("main") { - window.navigate(parsed).map_err(|err| anyhow!(err.to_string()))?; - let _ = window.set_focus(); - return Ok(()); - } - - create_window(app, url, local_origin, boot_outcome, true) -} - -/// Open a new window pointed at the default host (local or configured default). -/// -/// Known multi-window limitations (acceptable for v1): -/// -/// - **localStorage conflicts**: Windows sharing the same origin (e.g. both on local) -/// share `localStorage`. Zustand `persist` middleware writes full state blobs on every -/// change with no cross-tab sync, so concurrent windows can overwrite each other's -/// persisted UI preferences, session selections, and model/agent choices. -/// Server-side data is unaffected. Scoping storage keys per window or adding -/// `storage` event listeners would fix this in a future iteration. -/// -/// - **Duplicate SSE connections**: Each window opens its own SSE connection to -/// `/api/global/event`, resulting in N connections for N windows. Each window -/// independently processes all events and may show duplicate toast notifications. -/// A SharedWorker, BroadcastChannel leader-election, or Rust-side SSE relay -/// would consolidate this in a future iteration. -/// -/// - **Startup race**: If called before the sidecar finishes starting (local_origin -/// not yet set), this function silently bails with a log warning. The user sees -/// no feedback from clicking the dock icon during the startup window (~0-20s). -fn open_new_window(app: &tauri::AppHandle) { - let local_origin = app - .try_state::() - .and_then(|state| { - state - .local_origin - .lock() - .expect("desktop local origin mutex") - .clone() - }); - - let Some(local_origin) = local_origin else { - log::warn!("[desktop] cannot open new window: local origin not yet known (sidecar may still be starting)"); - return; - }; - - // Resolve the URL the same way as initial setup: env override, then default host, else local. - let local_url = app - .try_state::() - .and_then(|state| state.url.lock().expect("sidecar url mutex").clone()) - .unwrap_or_else(|| local_origin.clone()); - - let local_ui_url = if cfg!(debug_assertions) { - // In dev mode, prefer the Vite dev server if it was used as local origin. - local_origin.clone() - } else { - local_url.clone() - }; - - let env_target = std::env::var("OPENCHAMBER_SERVER_URL") - .ok() - .and_then(|raw| normalize_server_url(&raw)) - .filter(|url| !same_server_url(url, &local_ui_url)); - - let cfg = read_desktop_hosts_config_from_disk(); - - let target_url = if let Some(ref env_url) = env_target { - env_url.clone() - } else if let Some(default_id) = cfg.default_host_id.as_deref() { - if default_id == LOCAL_HOST_ID { - local_ui_url.clone() - } else { - cfg.hosts - .iter() - .find(|h| h.id == default_id) - .map(|h| h.url.clone()) - .unwrap_or(local_ui_url.clone()) - } - } else { - local_ui_url.clone() - }; - - // Compute boot outcome for the new window (no probe yet for sync local case). - let boot_outcome = resolve_boot_outcome( - &cfg, - None, - true, - env_target.as_deref(), - ); - - // If the target is local, create the window immediately on this (main) thread. - if same_server_url(&target_url, &local_ui_url) { - if let Err(err) = create_window(app, &target_url, &local_origin, Some(&boot_outcome), false) { - log::error!("[desktop] failed to create new window: {err}"); - } - return; - } - - // For remote hosts, probe asynchronously then dispatch window creation - // back to the main thread via run_on_main_thread (required on macOS). - // Uses the same probe_with_retry policy as startup (soft + hard). - let handle = app.clone(); - let cfg_snapshot = cfg.clone(); - let env_target_snapshot = env_target.clone(); - tauri::async_runtime::spawn(async move { - let result = probe_with_retry(&target_url).await; - - let final_url = if result.navigable { - target_url - } else { - log::info!( - "[desktop] new window: default host ({}) probe returned non-navigable status, using local", - target_url - ); - local_ui_url - }; - - // Recompute boot outcome with actual probe result, using the - // same config/env snapshot that chose this window's target. - let final_boot_outcome = resolve_boot_outcome( - &cfg_snapshot, - result.probe.as_ref(), - true, - env_target_snapshot.as_deref(), - ); - - let local = local_origin; - let handle_clone = handle.clone(); - if let Err(err) = handle.run_on_main_thread(move || { - if let Err(err) = create_window(&handle_clone, &final_url, &local, Some(&final_boot_outcome), false) { - log::error!("[desktop] failed to create new window: {err}"); - } - }) { - log::error!("[desktop] failed to dispatch window creation to main thread: {err}"); - } - }); -} - -fn main() { - // Ensure localhost traffic never routes through a system/VPN proxy. - for key in ["NO_PROXY", "no_proxy"] { - let existing = env::var(key).unwrap_or_default(); - let loopback = ["127.0.0.1", "localhost", "::1"]; - let missing: Vec<&str> = loopback - .iter() - .filter(|addr| !existing.split(',').any(|part| part.trim() == **addr)) - .copied() - .collect(); - if !missing.is_empty() { - let merged = if existing.is_empty() { - missing.join(",") - } else { - format!("{},{}", existing, missing.join(",")) - }; - env::set_var(key, &merged); - } - } - - let log_builder = tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .clear_targets() - .targets(if cfg!(debug_assertions) { - vec![ - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), - ] - } else { - vec![tauri_plugin_log::Target::new( - tauri_plugin_log::TargetKind::Stdout, - )] - }); - - let builder = tauri::Builder::default() - .manage(SidecarState::default()) - .manage(DesktopUiInjectionState::default()) - .manage(WindowFocusState::default()) - .manage(WindowGeometryDebounceState::default()) - .manage(DesktopSshManagerState::default()) - .manage(PendingUpdate(Mutex::new(None))) - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_notification::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(log_builder.build()) - .on_page_load(|window, _payload| { - if let Some(state) = window.app_handle().try_state::() { - let label = window.label().to_string(); - if let Ok(guard) = state.scripts.lock() { - if let Some(script) = guard.get(&label) { - let _ = window.eval(script); - } - } - } - }) - .menu(|app| { - #[cfg(target_os = "macos")] - { - build_macos_menu(app) - } - - #[cfg(not(target_os = "macos"))] - { - tauri::menu::Menu::default(app) - } - }) - .on_menu_event(|app, event| { - #[cfg(target_os = "macos")] - { - let id = event.id().as_ref(); - - log::info!("[menu] click id={}", id); - - #[cfg(debug_assertions)] - { - let msg = serde_json::to_string(id).unwrap_or_else(|_| "\"(unserializable)\"".into()); - eval_in_focused_window(app, &format!("console.log('[menu] id=', {});", msg)); - } - - if id == MENU_ITEM_NEW_WINDOW_ID { - open_new_window(app); - return; - } - - if id == MENU_ITEM_CHECK_FOR_UPDATES_ID { - dispatch_check_for_updates(app); - return; - } - - if id == MENU_ITEM_REPORT_BUG_ID { - use tauri_plugin_shell::ShellExt; - #[allow(deprecated)] - { - let _ = app.shell().open(GITHUB_BUG_REPORT_URL, None); - } - return; - } - - if id == MENU_ITEM_REQUEST_FEATURE_ID { - use tauri_plugin_shell::ShellExt; - #[allow(deprecated)] - { - let _ = app.shell().open(GITHUB_FEATURE_REQUEST_URL, None); - } - return; - } - - if id == MENU_ITEM_JOIN_DISCORD_ID { - use tauri_plugin_shell::ShellExt; - #[allow(deprecated)] - { - let _ = app.shell().open(DISCORD_INVITE_URL, None); - } - return; - } - - if id == MENU_ITEM_ABOUT_ID { - dispatch_menu_action(app, "about"); - return; - } - if id == MENU_ITEM_SETTINGS_ID { - dispatch_menu_action(app, "settings"); - return; - } - if id == MENU_ITEM_COMMAND_PALETTE_ID { - dispatch_menu_action(app, "command-palette"); - return; - } - if id == MENU_ITEM_NEW_SESSION_ID { - dispatch_menu_action(app, "new-session"); - return; - } - if id == MENU_ITEM_WORKTREE_CREATOR_ID { - dispatch_menu_action(app, "new-worktree-session"); - return; - } - if id == MENU_ITEM_CHANGE_WORKSPACE_ID { - dispatch_menu_action(app, "change-workspace"); - return; - } - - if id == MENU_ITEM_OPEN_GIT_TAB_ID { - dispatch_menu_action(app, "open-git-tab"); - return; - } - if id == MENU_ITEM_OPEN_DIFF_TAB_ID { - dispatch_menu_action(app, "open-diff-tab"); - return; - } - - if id == MENU_ITEM_OPEN_FILES_TAB_ID { - dispatch_menu_action(app, "open-files-tab"); - return; - } - if id == MENU_ITEM_OPEN_TERMINAL_TAB_ID { - dispatch_menu_action(app, "open-terminal-tab"); - return; - } - if id == MENU_ITEM_COPY_ID { - dispatch_menu_action(app, "copy"); - return; - } - - if id == MENU_ITEM_THEME_LIGHT_ID { - dispatch_menu_action(app, "theme-light"); - return; - } - if id == MENU_ITEM_THEME_DARK_ID { - dispatch_menu_action(app, "theme-dark"); - return; - } - if id == MENU_ITEM_THEME_SYSTEM_ID { - dispatch_menu_action(app, "theme-system"); - return; - } - - if id == MENU_ITEM_TOGGLE_SIDEBAR_ID { - dispatch_menu_action(app, "toggle-sidebar"); - return; - } - if id == MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID { - dispatch_menu_action(app, "toggle-memory-debug"); - return; - } - - if id == MENU_ITEM_HELP_DIALOG_ID { - dispatch_menu_action(app, "help-dialog"); - return; - } - if id == MENU_ITEM_DOWNLOAD_LOGS_ID { - dispatch_menu_action(app, "download-logs"); - return; - } - if id == MENU_ITEM_CLEAR_CACHE_ID { - let app = app.clone(); - tauri::async_runtime::spawn_blocking(move || { - let _ = crate::desktop_clear_cache(app); - }); - return; - } - if id == MENU_ITEM_QUIT_ID { - request_quit_with_confirmation(app); - return; - } - } - }) - .on_window_event(|window, event| { - let app = window.app_handle(); - let label = window.label().to_string(); - - if let tauri::WindowEvent::Focused(focused) = event { - if let Some(state) = app.try_state::() { - state.set_focused(&label, *focused); - } - } - - if let tauri::WindowEvent::Destroyed = event { - if let Some(state) = app.try_state::() { - state.remove_window(&label); - } - - if let Some(state) = app.try_state::() { - state - .scripts - .lock() - .expect("desktop ui injection mutex") - .remove(&label); - } - } - - if matches!(event, tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_)) { - schedule_window_state_persist(window.clone(), false); - } - - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - schedule_window_state_persist(window.clone(), true); - - let remaining_visible = app - .webview_windows() - .values() - .filter(|w| w.is_visible().unwrap_or(false)) - .count(); - - if remaining_visible <= 1 { - api.prevent_close(); - let _ = window.hide(); - } - } - }) - .invoke_handler(tauri::generate_handler![ - desktop_notify, - desktop_check_for_updates, - desktop_download_and_install_update, - desktop_restart, - desktop_new_window, - desktop_new_window_at_url, - desktop_clear_cache, - desktop_open_path, - desktop_open_in_app, - desktop_filter_installed_apps, - desktop_get_installed_apps, - desktop_fetch_app_icons, - desktop_save_markdown_file, - desktop_hosts_get, - desktop_hosts_set, - desktop_host_probe, - desktop_set_window_theme, - desktop_get_lan_address, - remote_ssh::desktop_ssh_instances_get, - remote_ssh::desktop_ssh_instances_set, - remote_ssh::desktop_ssh_import_hosts, - remote_ssh::desktop_ssh_connect, - remote_ssh::desktop_ssh_disconnect, - remote_ssh::desktop_ssh_status, - remote_ssh::desktop_ssh_logs, - remote_ssh::desktop_ssh_logs_clear, - desktop_read_file, - ]) - .setup(|app| { - let handle = app.handle().clone(); - - if let Err(err) = create_startup_window(&handle, true) { - log::error!("[desktop] failed to create startup window: {err}"); - } - - tauri::async_runtime::spawn(async move { - // Helper: inject a fallback boot outcome when the local server - // cannot start, so the UI leaves the splash and shows - // chooser/recovery instead of hanging on a white screen. - let handle_for_fallback = handle.clone(); - let inject_startup_failure = |err: String| { - log::error!("[desktop] failed to start local server: {err}"); - let cfg = read_desktop_hosts_config_from_disk(); - let boot_outcome = compute_local_startup_failure_boot_outcome(&cfg); - let init_script = build_startup_failure_init_script(&boot_outcome); - if let Some(state) = handle_for_fallback.try_state::() - { - state - .scripts - .lock() - .expect("desktop ui injection mutex") - .insert("main".to_string(), init_script.clone()); - } - if let Some(window) = handle_for_fallback.get_webview_window("main") { - let _ = window.eval(&init_script); - } - }; - - let local_url = if cfg!(debug_assertions) { - let dev_url = "http://127.0.0.1:3901".to_string(); - if wait_for_health(&dev_url).await { - dev_url.to_string() - } else { - match spawn_local_server(&handle).await { - Ok(local) => local, - Err(err) => { - inject_startup_failure(err.to_string()); - return; - } - } - } - } else { - match spawn_local_server(&handle).await { - Ok(local) => local, - Err(err) => { - inject_startup_failure(err.to_string()); - return; - } - } - }; - - let local_ui_url = if cfg!(debug_assertions) { - let vite_url = "http://127.0.0.1:5173"; - if wait_for_health(vite_url).await { - vite_url.to_string() - } else { - log::warn!("[desktop] Vite dev server not ready, using local API UI at {local_url}"); - local_url.clone() - } - } else { - local_url.clone() - }; - - // Ensure local URL is always available to desktop commands, - // even when we are using the Vite dev server (no sidecar child). - if let Some(state) = handle.try_state::() { - *state.url.lock().expect("sidecar url mutex") = Some(local_url.clone()); - } - start_quit_risk_poller(local_url.clone()); - - let local_origin = url::Url::parse(&local_ui_url) - .ok() - .map(|u| u.origin().ascii_serialization()) - .unwrap_or_else(|| local_ui_url.clone()); - - // Selected host: env override first, then desktop default host, else local. - // If env override points to the local server, ignore it and use - // config-based resolution instead. - let env_target = std::env::var("OPENCHAMBER_SERVER_URL") - .ok() - .and_then(|raw| normalize_server_url(&raw)) - .filter(|url| !same_server_url(url, &local_ui_url)); - - let mut initial_url = env_target.as_deref().unwrap_or(&local_ui_url).to_string(); - - // Compute boot outcome and legacy-upgrade if needed. - let cfg = read_desktop_hosts_config_from_disk(); - - if env_target.is_none() { - if let Some(default_id) = cfg.default_host_id.as_deref() { - if default_id != LOCAL_HOST_ID { - if let Some(host) = cfg.hosts.iter().find(|h| h.id == default_id) { - initial_url = host.url.clone(); - } - } - } - } - - // If remote, probe and fall back to local if unreachable. - // Use the shared probe_with_retry policy (soft + hard). - let final_probe: Option = if !same_server_url(&initial_url, &local_ui_url) { - let result = probe_with_retry(&initial_url).await; - - if !result.navigable { - log::warn!( - "[desktop] startup host unreachable after retries ({}), falling back to local ({})", - initial_url, - local_ui_url - ); - initial_url = local_ui_url.clone(); - } - - result.probe - } else { - None - }; - - // Probe the local server to verify opencode is actually running. - // spawn_local_server only confirms the sidecar web server responded - // HTTP 200 — it does not check whether opencode CLI is ready. - let local_available = match wait_for_local_opencode_ready_with( - &local_url, - LOCAL_SIDECAR_HEALTH_TIMEOUT, - LOCAL_SIDECAR_HEALTH_POLL_INITIAL_INTERVAL, - LOCAL_SIDECAR_HEALTH_POLL_MAX_INTERVAL, - ) - .await - { - Some(probe) if matches!(probe.status.as_str(), "ok" | "auth") => { - log::info!("[desktop] local opencode verified (status={})", probe.status); - true - } - Some(probe) => { - log::warn!( - "[desktop] local server up but opencode not ready (status={}), treating as unavailable", - probe.status - ); - false - } - None => { - log::warn!("[desktop] local opencode probe failed, treating as unavailable"); - false - } - }; - - let boot_outcome = resolve_boot_outcome( - &cfg, - final_probe.as_ref(), - local_available, - env_target.as_deref(), - ); - - if let Err(err) = activate_main_window( - &handle, - &initial_url, - &local_origin, - Some(&boot_outcome), - ) { - log::error!("[desktop] failed to activate main window: {err}"); - } - }); - - Ok(()) - }) - ; - - let app = builder - .build(tauri::generate_context!()) - .expect("failed to build Tauri application"); - - install_macos_quit_confirmation_hook(); - - app.run(|app_handle, event| { - match event { - tauri::RunEvent::ExitRequested { api, .. } => { - use std::sync::atomic::Ordering; - if !QUIT_CONFIRMED.load(Ordering::SeqCst) { - api.prevent_exit(); - #[cfg(target_os = "macos")] - request_quit_with_confirmation(app_handle); - return; - } - if let Some(state) = app_handle.try_state::() { - state.shutdown_all(app_handle); - } - kill_sidecar(app_handle.clone()); - } - tauri::RunEvent::Exit => { - if let Some(state) = app_handle.try_state::() { - state.shutdown_all(app_handle); - } - kill_sidecar(app_handle.clone()); - } - #[cfg(target_os = "macos")] - tauri::RunEvent::Reopen { - has_visible_windows, - .. - } => { - if !has_visible_windows { - let windows = app_handle.webview_windows(); - let hidden = windows - .values() - .find(|w| !w.is_visible().unwrap_or(true)); - if let Some(w) = hidden { - let _ = w.show(); - let _ = w.set_focus(); - } else { - drop(windows); - open_new_window(app_handle); - } - } - } - _ => {} - } - }); -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_settings_path(test_name: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock drift") - .as_nanos(); - std::env::temp_dir().join(format!("openchamber-{test_name}-{nanos}-settings.json")) - } - - #[test] - fn sanitize_host_url_for_storage_keeps_query_params() { - let input = "https://example.com?coder_session_token=xxxxxx"; - let sanitized = sanitize_host_url_for_storage(input).expect("sanitized url"); - assert_eq!(sanitized, "https://example.com/?coder_session_token=xxxxxx"); - } - - #[test] - fn sanitize_host_url_for_storage_strips_fragment_and_keeps_query() { - let input = "https://example.com/workspace?coder_session_token=xxxxxx#ignored"; - let sanitized = sanitize_host_url_for_storage(input).expect("sanitized url"); - assert_eq!( - sanitized, - "https://example.com/workspace?coder_session_token=xxxxxx" - ); - } - - #[test] - fn write_and_read_hosts_config_preserves_query_params() { - let path = unique_settings_path("desktop-hosts-query"); - let config = DesktopHostsConfig { - hosts: vec![DesktopHost { - id: "remote-1".to_string(), - label: "Remote".to_string(), - url: "https://example.com?coder_session_token=xxxxxx".to_string(), - }], - default_host_id: Some("remote-1".to_string()), - initial_host_choice_completed: false, - }; - - write_desktop_hosts_config_to_path(&path, &config).expect("write config"); - let read_back = read_desktop_hosts_config_from_path(&path); - let _ = fs::remove_file(&path); - - assert_eq!(read_back.hosts.len(), 1); - assert_eq!( - read_back.hosts[0].url, - "https://example.com/?coder_session_token=xxxxxx" - ); - assert_eq!(read_back.default_host_id.as_deref(), Some("remote-1")); - } - - #[test] - fn read_hosts_config_defaults_initial_choice_flag_to_false() { - let path = unique_settings_path("desktop-hosts-default-flag"); - std::fs::write(&path, r#"{"desktopHosts":[],"desktopDefaultHostId":null}"#).unwrap(); - - let cfg = read_desktop_hosts_config_from_path(&path); - let _ = fs::remove_file(&path); - assert_eq!(cfg.initial_host_choice_completed, false); - } - - #[test] - fn write_and_read_hosts_config_preserves_initial_choice_flag() { - let path = unique_settings_path("desktop-hosts-preserve-flag"); - let cfg = DesktopHostsConfig { - hosts: vec![], - default_host_id: Some(LOCAL_HOST_ID.to_string()), - initial_host_choice_completed: true, - }; - - write_desktop_hosts_config_to_path(&path, &cfg).unwrap(); - let reread = read_desktop_hosts_config_from_path(&path); - let _ = fs::remove_file(&path); - - assert_eq!(reread.default_host_id.as_deref(), Some(LOCAL_HOST_ID)); - assert!(reread.initial_host_choice_completed); - } - - #[test] - fn omitted_initial_choice_flag_preserves_stored_true() { - let path = unique_settings_path("desktop-hosts-omit-preserves"); - - // Seed: write config with initialHostChoiceCompleted = true - let seed = DesktopHostsConfig { - hosts: vec![DesktopHost { - id: "remote-1".to_string(), - label: "Remote".to_string(), - url: "https://example.com".to_string(), - }], - default_host_id: Some("remote-1".to_string()), - initial_host_choice_completed: true, - }; - write_desktop_hosts_config_to_path(&path, &seed).unwrap(); - - // Call the production merge-and-write path with omitted field - let input = DesktopHostsConfigInput { - hosts: vec![], - default_host_id: Some("local".to_string()), - initial_host_choice_completed: None, - }; - write_desktop_hosts_config_input_to_path(&path, &input).unwrap(); - - let reread = read_desktop_hosts_config_from_path(&path); - let _ = fs::remove_file(&path); - - // The stored true must be preserved, not reset to false - assert!(reread.initial_host_choice_completed); - } - - // --- Task 2: probe validation tests --- - - /// Spawn a tiny HTTP server on a random port that responds with `status_code` - /// and `body`. Returns the base URL (e.g. `http://127.0.0.1:{port}`). - async fn spawn_test_http_server(status_code: u16, body: &str) -> String { - use tokio::net::TcpListener; - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); - let port = listener.local_addr().unwrap().port(); - let body_owned = body.to_string(); - - tokio::spawn(async move { - loop { - let (mut stream, _) = tokio::select! { - res = listener.accept() => { res.expect("accept") } - else => break, - }; - use tokio::io::AsyncWriteExt; - let response = format!( - "HTTP/1.1 {status_code} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body_owned}", - body_owned.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - } - }); - - format!("http://127.0.0.1:{port}") - } - - #[tokio::test] - async fn probe_returns_wrong_service_for_generic_http_200_health() { - let url = spawn_test_http_server(200, r#"{"status":"ok","uptime":42}"#).await; - // Give the server a moment to start listening - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let result = probe_host_with_timeout(&url, Duration::from_secs(2)) - .await - .expect("probe should not error"); - assert_eq!(result.status, "wrong-service"); - } - - #[tokio::test] - async fn probe_returns_ok_for_valid_openchamber_health_payload() { - let url = spawn_test_http_server(200, r#"{"openCodeRunning":true}"#).await; - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let result = probe_host_with_timeout(&url, Duration::from_secs(2)) - .await - .expect("probe should not error"); - assert_eq!(result.status, "ok"); - } - - #[tokio::test] - async fn probe_returns_auth_for_401_health() { - let url = spawn_test_http_server(401, r#"{"message":"unauthorized"}"#).await; - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let result = probe_host_with_timeout(&url, Duration::from_secs(2)) - .await - .expect("probe should not error"); - assert_eq!(result.status, "auth"); - } - - async fn spawn_flaky_openchamber_health_server() -> String { - use tokio::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind flaky test server"); - let port = listener.local_addr().unwrap().port(); - let request_count = Arc::new(AtomicUsize::new(0)); - - tokio::spawn({ - let request_count = Arc::clone(&request_count); - async move { - loop { - let (mut stream, _) = tokio::select! { - res = listener.accept() => { res.expect("accept") } - else => break, - }; - - let count = request_count.fetch_add(1, Ordering::SeqCst); - let body = if count == 0 { - r#"{"status":"ok","openCodeRunning":false,"isOpenCodeReady":false}"# - } else { - r#"{"status":"ok","openCodeRunning":true,"isOpenCodeReady":true}"# - }; - - use tokio::io::AsyncWriteExt; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - } - } - }); - - format!("http://127.0.0.1:{port}") - } - - #[tokio::test] - async fn wait_for_local_opencode_ready_retries_until_health_payload_is_ready() { - let url = spawn_flaky_openchamber_health_server().await; - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let result = wait_for_local_opencode_ready_with( - &url, - Duration::from_millis(200), - Duration::from_millis(10), - Duration::from_millis(20), - ) - .await - .expect("probe result"); - - assert_eq!(result.status, "ok"); - } - - #[tokio::test] - async fn wait_for_local_opencode_ready_returns_last_probe_when_server_never_becomes_ready() { - let url = spawn_test_http_server( - 200, - r#"{"status":"ok","openCodeRunning":false,"isOpenCodeReady":false}"#, - ) - .await; - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let result = wait_for_local_opencode_ready_with( - &url, - Duration::from_millis(120), - Duration::from_millis(10), - Duration::from_millis(20), - ) - .await - .expect("probe result"); - - assert_eq!(result.status, "wrong-service"); - } - - // --- Task 3: boot outcome resolution tests --- - - fn make_config( - hosts: Vec<(&str, &str, &str)>, - default_host_id: Option<&str>, - initial_host_choice_completed: bool, - ) -> DesktopHostsConfig { - DesktopHostsConfig { - hosts: hosts - .into_iter() - .map(|(id, label, url)| DesktopHost { - id: id.to_string(), - label: label.to_string(), - url: url.to_string(), - }) - .collect(), - default_host_id: default_host_id.map(|s| s.to_string()), - initial_host_choice_completed, - } - } - - #[test] - fn resolve_boot_outcome_returns_first_launch_when_no_default_and_choice_not_completed() { - let cfg = make_config(vec![], None, false); - let probe: Option<&HostProbeResult> = None; - let outcome = resolve_boot_outcome(&cfg, probe, true, None); - assert_eq!(outcome.target, None); - assert_eq!(outcome.status, "not-configured"); - } - - #[test] - fn resolve_boot_outcome_returns_recovery_no_default_host_when_choice_completed_but_default_missing() { - let cfg = make_config( - vec![("remote-a", "Remote A", "https://a.test")], - None, - true, - ); - let probe: Option<&HostProbeResult> = None; - let outcome = resolve_boot_outcome(&cfg, probe, true, None); - assert_eq!(outcome.target, None); - assert_eq!(outcome.status, "not-configured"); - } - - #[test] - fn resolve_boot_outcome_returns_recovery_missing_default_host_when_default_id_has_no_matching_host() { - let cfg = make_config(vec![], Some("gone-1"), true); - let probe: Option<&HostProbeResult> = None; - let outcome = resolve_boot_outcome(&cfg, probe, true, None); - assert_eq!(outcome.target, Some("remote".to_string())); - assert_eq!(outcome.status, "missing"); - assert_eq!(outcome.host_id.as_deref(), Some("gone-1")); - } - - #[test] - fn resolve_boot_outcome_returns_main_local_when_default_is_local_and_available() { - let cfg = make_config(vec![], Some("local"), true); - let probe: Option<&HostProbeResult> = None; - let outcome = resolve_boot_outcome(&cfg, probe, true, None); - assert_eq!(outcome.target, Some("local".to_string())); - assert_eq!(outcome.status, "ok"); - } - - #[test] - fn resolve_boot_outcome_returns_recovery_local_unavailable_when_local_is_default_but_unavailable() { - let cfg = make_config(vec![], Some("local"), true); - let probe: Option<&HostProbeResult> = None; - let outcome = resolve_boot_outcome(&cfg, probe, false, None); - assert_eq!(outcome.target, Some("local".to_string())); - assert_eq!(outcome.status, "unreachable"); - } - - #[test] - fn resolve_boot_outcome_returns_main_remote_when_probe_is_ok() { - let cfg = make_config( - vec![("remote-a", "Remote A", "https://a.test")], - Some("remote-a"), - true, - ); - let probe = HostProbeResult { - status: "ok".to_string(), - latency_ms: 10, - }; - let outcome = resolve_boot_outcome(&cfg, Some(&probe), true, None); - assert_eq!(outcome.target, Some("remote".to_string())); - assert_eq!(outcome.status, "ok"); - assert_eq!(outcome.host_id.as_deref(), Some("remote-a")); - assert_eq!(outcome.url.as_deref(), Some("https://a.test")); - } - - #[test] - fn resolve_boot_outcome_returns_main_remote_when_probe_is_auth() { - let cfg = make_config( - vec![("remote-a", "Remote A", "https://a.test")], - Some("remote-a"), - true, - ); - let probe = HostProbeResult { - status: "auth".to_string(), - latency_ms: 10, - }; - let outcome = resolve_boot_outcome(&cfg, Some(&probe), true, None); - assert_eq!(outcome.target, Some("remote".to_string())); - assert_eq!(outcome.status, "ok"); - } - - #[test] - fn resolve_boot_outcome_returns_recovery_remote_unreachable_when_probe_fails() { - let cfg = make_config( - vec![("remote-a", "Remote A", "https://a.test")], - Some("remote-a"), - true, - ); - let probe = HostProbeResult { - status: "unreachable".to_string(), - latency_ms: 2000, - }; - let outcome = resolve_boot_outcome(&cfg, Some(&probe), true, None); - assert_eq!(outcome.target, Some("remote".to_string())); - assert_eq!(outcome.status, "unreachable"); - assert_eq!(outcome.host_id.as_deref(), Some("remote-a")); - } - - #[test] - fn resolve_boot_outcome_returns_recovery_remote_wrong_service_when_probe_says_wrong_service() { - let cfg = make_config( - vec![("remote-a", "Remote A", "https://a.test")], - Some("remote-a"), - true, - ); - let probe = HostProbeResult { - status: "wrong-service".to_string(), - latency_ms: 50, - }; - let outcome = resolve_boot_outcome(&cfg, Some(&probe), true, None); - assert_eq!(outcome.target, Some("remote".to_string())); - assert_eq!(outcome.status, "wrong-service"); - assert_eq!(outcome.host_id.as_deref(), Some("remote-a")); - } - - #[test] - fn resolve_boot_outcome_no_probe_but_remote_default_returns_unreachable() { - // Remote default but no probe result yet — treat as unreachable - // (probe hasn't happened yet, but user has already chosen a remote) - let cfg = make_config( - vec![("remote-a", "Remote A", "https://a.test")], - Some("remote-a"), - false, - ); - let probe: Option<&HostProbeResult> = None; - let outcome = resolve_boot_outcome(&cfg, probe, true, None); - assert_eq!(outcome.target, Some("remote".to_string())); - assert_eq!(outcome.status, "unreachable"); - assert_eq!(outcome.host_id.as_deref(), Some("remote-a")); - } - - // --- Startup failure fallback boot outcome tests --- - - #[test] - fn startup_failure_returns_recovery_local_unavailable_when_default_is_local() { - let cfg = make_config(vec![], Some("local"), true); - let outcome = compute_local_startup_failure_boot_outcome(&cfg); - assert_eq!(outcome.target, Some("local".to_string())); - assert_eq!(outcome.status, "unreachable"); - } - - #[test] - fn startup_failure_returns_first_launch_when_no_default_and_choice_not_completed() { - let cfg = make_config(vec![], None, false); - let outcome = compute_local_startup_failure_boot_outcome(&cfg); - assert_eq!(outcome.target, None); - assert_eq!(outcome.status, "not-configured"); - } - - #[test] - fn startup_failure_returns_recovery_no_default_host_when_choice_completed_but_no_default() { - let cfg = make_config(vec![], None, true); - let outcome = compute_local_startup_failure_boot_outcome(&cfg); - assert_eq!(outcome.target, None); - assert_eq!(outcome.status, "not-configured"); - } - - #[test] - fn startup_failure_never_returns_main_outcome() { - // When the local server fails to start, the fallback outcome must - // never be a "main-*" variant because the startup-failure path - // only injects globals into the already-open startup window — it - // does NOT navigate to a remote URL. A "main-*" outcome would - // gate splash dismissal on initialization and hang. - let cfg = make_config(vec![], None, false); - let outcome = compute_local_startup_failure_boot_outcome(&cfg); - assert!( - outcome.status != "ok", - "startup failure fallback must not return main-* outcome, got: {:?}", - outcome - ); - } - - #[test] - fn startup_failure_init_script_contains_boot_outcome_json() { - let cfg = make_config(vec![], Some("local"), true); - let outcome = compute_local_startup_failure_boot_outcome(&cfg); - let script = build_startup_failure_init_script(&outcome); - // The script must contain the serialized boot outcome JSON. - assert!( - script.contains(r#""target":"local""#) && script.contains(r#""status":"unreachable""#), - "init script should embed the structured boot outcome" - ); - // It must also set __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ - assert!( - script.contains("__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__"), - "init script must set __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__" - ); - } -} diff --git a/packages/desktop/src-tauri/src/remote_ssh.rs b/packages/desktop/src-tauri/src/remote_ssh.rs deleted file mode 100644 index af37c8d6..00000000 --- a/packages/desktop/src-tauri/src/remote_ssh.rs +++ /dev/null @@ -1,3002 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::{ - collections::{HashMap, HashSet}, - fs, - io::Read, - net::{TcpListener, TcpStream}, - path::{Path, PathBuf}, - process::{Child, Command, Stdio}, - sync::{Arc, Mutex}, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; -use tauri::{AppHandle, Emitter, State}; - -const LOCAL_HOST_ID: &str = "local"; -const SSH_STATUS_EVENT: &str = "openchamber:ssh-instance-status"; -const DEFAULT_CONNECTION_TIMEOUT_SEC: u16 = 60; -const DEFAULT_LOCAL_BIND_HOST: &str = "127.0.0.1"; -const DEFAULT_CONTROL_PERSIST_SEC: u16 = 300; -const DEFAULT_READY_TIMEOUT_SEC: u64 = 30; -const DEFAULT_RECONNECT_MAX_ATTEMPTS: u32 = 5; -const MAX_LOG_LINES_PER_INSTANCE: usize = 1200; - -/// Monitor starts with fast polling and relaxes to steady-state after stabilization. -const MONITOR_INITIAL_POLL_SECS: u64 = 2; -const MONITOR_STEADY_POLL_SECS: u64 = 10; -/// Number of healthy ticks before switching from initial to steady-state polling. -const MONITOR_STABILIZE_TICKS: u32 = 5; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshInstancesConfig { - pub instances: Vec, -} - -impl Default for DesktopSshInstancesConfig { - fn default() -> Self { - Self { - instances: Vec::new(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshParsedCommand { - pub destination: String, - pub args: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DesktopSshRemoteMode { - Managed, - External, -} - -impl Default for DesktopSshRemoteMode { - fn default() -> Self { - Self::Managed - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DesktopSshInstallMethod { - Npm, - Bun, - DownloadRelease, - UploadBundle, -} - -impl Default for DesktopSshInstallMethod { - fn default() -> Self { - Self::Bun - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshRemoteOpenchamberConfig { - #[serde(default)] - pub mode: DesktopSshRemoteMode, - #[serde(default = "default_true")] - pub keep_running: bool, - pub preferred_port: Option, - #[serde(default)] - pub install_method: DesktopSshInstallMethod, - #[serde(default)] - pub upload_bundle_over_ssh: bool, -} - -impl Default for DesktopSshRemoteOpenchamberConfig { - fn default() -> Self { - Self { - mode: DesktopSshRemoteMode::Managed, - keep_running: true, - preferred_port: None, - install_method: DesktopSshInstallMethod::Bun, - upload_bundle_over_ssh: false, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshLocalForwardConfig { - pub preferred_local_port: Option, - #[serde(default = "default_local_bind_host")] - pub bind_host: String, -} - -impl Default for DesktopSshLocalForwardConfig { - fn default() -> Self { - Self { - preferred_local_port: None, - bind_host: default_local_bind_host(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DesktopSshSecretStore { - Never, - Settings, -} - -impl Default for DesktopSshSecretStore { - fn default() -> Self { - Self::Never - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshStoredSecret { - #[serde(default)] - pub enabled: bool, - pub value: Option, - #[serde(default)] - pub store: DesktopSshSecretStore, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshAuthConfig { - pub ssh_password: Option, - pub openchamber_password: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DesktopSshPortForwardType { - Local, - Remote, - Dynamic, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshPortForward { - pub id: String, - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(rename = "type")] - pub forward_type: DesktopSshPortForwardType, - pub local_host: Option, - pub local_port: Option, - pub remote_host: Option, - pub remote_port: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshInstance { - pub id: String, - pub nickname: Option, - pub ssh_command: String, - pub ssh_parsed: Option, - #[serde(default = "default_connection_timeout")] - pub connection_timeout_sec: u16, - #[serde(default)] - pub remote_openchamber: DesktopSshRemoteOpenchamberConfig, - #[serde(default)] - pub local_forward: DesktopSshLocalForwardConfig, - #[serde(default)] - pub auth: DesktopSshAuthConfig, - #[serde(default)] - pub port_forwards: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum DesktopSshPhase { - Idle, - ConfigResolved, - AuthCheck, - MasterConnecting, - RemoteProbe, - Installing, - Updating, - ServerDetecting, - ServerStarting, - Forwarding, - Ready, - Degraded, - Error, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshInstanceStatus { - pub id: String, - pub phase: DesktopSshPhase, - pub detail: Option, - pub local_url: Option, - pub local_port: Option, - pub remote_port: Option, - #[serde(default)] - pub started_by_us: bool, - #[serde(default)] - pub retry_attempt: u32, - #[serde(default)] - pub requires_user_action: bool, - pub updated_at_ms: u64, -} - -impl DesktopSshInstanceStatus { - fn idle(id: impl Into) -> Self { - Self { - id: id.into(), - phase: DesktopSshPhase::Idle, - detail: None, - local_url: None, - local_port: None, - remote_port: None, - started_by_us: false, - retry_attempt: 0, - requires_user_action: false, - updated_at_ms: now_millis(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopSshImportCandidate { - pub host: String, - pub pattern: bool, - pub source: String, - pub ssh_command: String, -} - -#[derive(Default)] -struct DesktopSshManagerInner { - statuses: Mutex>, - logs: Mutex>>, - sessions: Mutex>, - connect_tasks: Mutex>>, - monitor_tasks: Mutex>>, - reconnect_attempts: Mutex>, - connect_attempts: Mutex>, -} - -struct SshSession { - instance: DesktopSshInstance, - parsed: DesktopSshParsedCommand, - session_dir: PathBuf, - control_path: PathBuf, - local_port: u16, - remote_port: u16, - started_by_us: bool, - master: Child, - master_detached: bool, - main_forward: Child, - main_forward_detached: bool, - extra_forwards: Vec, -} - -#[derive(Default)] -pub struct DesktopSshManagerState { - inner: Arc, -} - -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RemoteSystemInfo { - openchamber_version: Option, - runtime: Option, - pid: Option, - started_at: Option, -} - -fn default_true() -> bool { - true -} - -fn default_connection_timeout() -> u16 { - DEFAULT_CONNECTION_TIMEOUT_SEC -} - -fn default_local_bind_host() -> String { - DEFAULT_LOCAL_BIND_HOST.to_string() -} - -fn now_millis() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -fn settings_file_path() -> PathBuf { - if let Ok(dir) = std::env::var("OPENCHAMBER_DATA_DIR") { - if !dir.trim().is_empty() { - return PathBuf::from(dir.trim()).join("settings.json"); - } - } - let home = std::env::var("HOME").unwrap_or_default(); - PathBuf::from(home) - .join(".config") - .join("openchamber") - .join("settings.json") -} - -fn read_settings_root(path: &Path) -> Value { - let raw = fs::read_to_string(path).unwrap_or_default(); - let parsed = serde_json::from_str::(&raw).unwrap_or_else(|_| json!({})); - if parsed.is_object() { - parsed - } else { - json!({}) - } -} - -fn write_settings_root(path: &Path, root: &Value) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, serde_json::to_string_pretty(root)?)?; - Ok(()) -} - -fn build_display_label(instance: &DesktopSshInstance) -> String { - if let Some(nick) = instance - .nickname - .as_ref() - .map(|v| v.trim()) - .filter(|v| !v.is_empty()) - { - return nick.to_string(); - } - if let Some(parsed) = instance.ssh_parsed.as_ref() { - let destination = parsed.destination.trim(); - if !destination.is_empty() { - return destination.to_string(); - } - } - instance.id.clone() -} - -fn read_desktop_ssh_instances_from_path(path: &Path) -> DesktopSshInstancesConfig { - let root = read_settings_root(path); - let Some(items) = root - .get("desktopSshInstances") - .and_then(Value::as_array) - .cloned() - else { - return DesktopSshInstancesConfig::default(); - }; - - let mut instances = Vec::new(); - let mut seen = HashSet::new(); - for item in items { - let Ok(mut instance) = serde_json::from_value::(item) else { - continue; - }; - - let id = instance.id.trim().to_string(); - if id.is_empty() || id == LOCAL_HOST_ID || seen.contains(&id) { - continue; - } - instance.id = id.clone(); - instance.connection_timeout_sec = if instance.connection_timeout_sec == 0 { - DEFAULT_CONNECTION_TIMEOUT_SEC - } else { - instance.connection_timeout_sec - }; - if instance.local_forward.bind_host.trim().is_empty() { - instance.local_forward.bind_host = default_local_bind_host(); - } - if instance.ssh_parsed.is_none() { - if let Ok(parsed) = parse_ssh_command(&instance.ssh_command) { - instance.ssh_parsed = Some(parsed); - } - } - seen.insert(id); - instances.push(instance); - } - - DesktopSshInstancesConfig { instances } -} - -fn read_desktop_ssh_instances_from_disk() -> DesktopSshInstancesConfig { - read_desktop_ssh_instances_from_path(&settings_file_path()) -} - -fn sanitize_bind_host(raw: &str) -> String { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return DEFAULT_LOCAL_BIND_HOST.to_string(); - } - match trimmed { - "127.0.0.1" | "localhost" | "0.0.0.0" => trimmed.to_string(), - _ => DEFAULT_LOCAL_BIND_HOST.to_string(), - } -} - -fn sanitize_forward(forward: &DesktopSshPortForward) -> Option { - let id = forward.id.trim().to_string(); - if id.is_empty() { - return None; - } - - let mut normalized = forward.clone(); - normalized.id = id; - normalized.local_host = normalized - .local_host - .as_ref() - .map(|v| sanitize_bind_host(v)) - .or_else(|| Some(DEFAULT_LOCAL_BIND_HOST.to_string())); - - match normalized.forward_type { - DesktopSshPortForwardType::Local => { - if normalized.local_port.is_none() || normalized.remote_port.is_none() { - return None; - } - if normalized - .remote_host - .as_ref() - .map(|v| v.trim()) - .unwrap_or("") - .is_empty() - { - normalized.remote_host = Some("127.0.0.1".to_string()); - } - } - DesktopSshPortForwardType::Remote => { - if normalized.local_port.is_none() || normalized.remote_port.is_none() { - return None; - } - if normalized - .remote_host - .as_ref() - .map(|v| v.trim()) - .unwrap_or("") - .is_empty() - { - normalized.remote_host = Some("127.0.0.1".to_string()); - } - if normalized - .local_host - .as_ref() - .map(|v| v.trim()) - .unwrap_or("") - .is_empty() - { - normalized.local_host = Some("127.0.0.1".to_string()); - } - } - DesktopSshPortForwardType::Dynamic => { - if normalized.local_port.is_none() { - return None; - } - normalized.remote_host = None; - normalized.remote_port = None; - } - } - - Some(normalized) -} - -fn sanitize_instance(mut instance: DesktopSshInstance) -> Result { - instance.id = instance.id.trim().to_string(); - if instance.id.is_empty() || instance.id == LOCAL_HOST_ID { - return Err(anyhow!("SSH instance id is required")); - } - instance.ssh_command = instance.ssh_command.trim().to_string(); - if instance.ssh_command.is_empty() { - return Err(anyhow!("SSH command is required")); - } - if instance.connection_timeout_sec == 0 { - instance.connection_timeout_sec = DEFAULT_CONNECTION_TIMEOUT_SEC; - } - instance.local_forward.bind_host = sanitize_bind_host(&instance.local_forward.bind_host); - let parsed = parse_ssh_command(&instance.ssh_command)?; - instance.ssh_parsed = Some(parsed); - - let mut seen = HashSet::new(); - let mut forwards = Vec::new(); - for forward in &instance.port_forwards { - let Some(normalized) = sanitize_forward(forward) else { - continue; - }; - if seen.contains(&normalized.id) { - continue; - } - seen.insert(normalized.id.clone()); - forwards.push(normalized); - } - instance.port_forwards = forwards; - - Ok(instance) -} - -fn sync_desktop_hosts_for_ssh( - root: &mut Value, - previous_ids: &HashSet, - instances: &[DesktopSshInstance], -) { - let next_ids: HashSet = instances.iter().map(|item| item.id.clone()).collect(); - - let mut hosts = root - .get("desktopHosts") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - hosts.retain(|entry| { - let id = entry - .get("id") - .and_then(Value::as_str) - .map(|value| value.trim()) - .unwrap_or(""); - if id.is_empty() { - return false; - } - !(previous_ids.contains(id) && !next_ids.contains(id)) - }); - - for instance in instances { - let label = build_display_label(instance); - let mut found = false; - for host in &mut hosts { - let host_id = host - .get("id") - .and_then(Value::as_str) - .map(|value| value.trim()) - .unwrap_or(""); - if host_id != instance.id { - continue; - } - if let Some(obj) = host.as_object_mut() { - obj.insert("id".to_string(), Value::String(instance.id.clone())); - obj.insert("label".to_string(), Value::String(label.clone())); - let should_set_default_url = obj - .get("url") - .and_then(Value::as_str) - .map(|value| value.trim().is_empty()) - .unwrap_or(true); - if should_set_default_url { - obj.insert( - "url".to_string(), - Value::String("http://127.0.0.1/".to_string()), - ); - } - } - found = true; - break; - } - - if !found { - hosts.push(json!({ - "id": instance.id, - "label": label, - "url": "http://127.0.0.1/" - })); - } - } - - root["desktopHosts"] = Value::Array(hosts); - - let default_id = root - .get("desktopDefaultHostId") - .and_then(Value::as_str) - .map(|value| value.trim().to_string()) - .unwrap_or_default(); - if !default_id.is_empty() - && previous_ids.contains(default_id.as_str()) - && !next_ids.contains(default_id.as_str()) - { - root["desktopDefaultHostId"] = Value::String(LOCAL_HOST_ID.to_string()); - } -} - -fn write_desktop_ssh_instances_to_path( - path: &Path, - config: DesktopSshInstancesConfig, -) -> Result { - let mut root = read_settings_root(path); - let previous = read_desktop_ssh_instances_from_path(path); - let previous_ids: HashSet = previous - .instances - .iter() - .map(|instance| instance.id.clone()) - .collect(); - - let mut seen = HashSet::new(); - let mut sanitized = Vec::new(); - - for instance in config.instances { - let normalized = sanitize_instance(instance)?; - if seen.contains(&normalized.id) { - continue; - } - seen.insert(normalized.id.clone()); - sanitized.push(normalized); - } - - sync_desktop_hosts_for_ssh(&mut root, &previous_ids, &sanitized); - root["desktopSshInstances"] = serde_json::to_value(&sanitized)?; - write_settings_root(path, &root)?; - - Ok(DesktopSshInstancesConfig { - instances: sanitized, - }) -} - -fn update_ssh_host_url(instance_id: &str, label: &str, local_url: &str) -> Result<()> { - let path = settings_file_path(); - let mut root = read_settings_root(&path); - let mut hosts = root - .get("desktopHosts") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - let mut found = false; - for host in &mut hosts { - let host_id = host - .get("id") - .and_then(Value::as_str) - .map(|value| value.trim()) - .unwrap_or(""); - if host_id != instance_id { - continue; - } - if let Some(obj) = host.as_object_mut() { - obj.insert("id".to_string(), Value::String(instance_id.to_string())); - obj.insert("label".to_string(), Value::String(label.to_string())); - obj.insert("url".to_string(), Value::String(local_url.to_string())); - found = true; - break; - } - } - - if !found { - hosts.push(json!({ - "id": instance_id, - "label": label, - "url": local_url - })); - } - - root["desktopHosts"] = Value::Array(hosts); - write_settings_root(&path, &root) -} - -fn persist_local_port_for_instance(instance_id: &str, local_port: u16) -> Result<()> { - let path = settings_file_path(); - let mut root = read_settings_root(&path); - let mut changed = false; - - if let Some(items) = root - .get_mut("desktopSshInstances") - .and_then(Value::as_array_mut) - { - for item in items { - let Some(id) = item.get("id").and_then(Value::as_str) else { - continue; - }; - if id.trim() != instance_id { - continue; - } - if item - .get("localForward") - .and_then(Value::as_object) - .is_none() - { - item["localForward"] = json!({}); - } - item["localForward"]["preferredLocalPort"] = Value::Number(local_port.into()); - changed = true; - break; - } - } - - if changed { - write_settings_root(&path, &root)?; - } - - Ok(()) -} - -fn split_shell_words(input: &str) -> Result> { - let mut tokens = Vec::new(); - let mut current = String::new(); - let mut chars = input.chars().peekable(); - let mut in_single = false; - let mut in_double = false; - - while let Some(ch) = chars.next() { - match ch { - '\\' if !in_single => { - if let Some(next) = chars.next() { - current.push(next); - } - } - '\'' if !in_double => { - in_single = !in_single; - } - '"' if !in_single => { - in_double = !in_double; - } - c if c.is_whitespace() && !in_single && !in_double => { - if !current.is_empty() { - tokens.push(std::mem::take(&mut current)); - } - } - _ => current.push(ch), - } - } - - if in_single || in_double { - return Err(anyhow!("Unclosed quote in SSH command")); - } - - if !current.is_empty() { - tokens.push(current); - } - - Ok(tokens) -} - -fn is_disallowed_primary_flag(token: &str) -> bool { - const DISALLOWED: [&str; 17] = [ - "-M", "-S", "-O", "-N", "-t", "-T", "-f", "-G", "-W", "-v", "-V", "-q", "-n", "-s", "-e", - "-E", "-g", - ]; - DISALLOWED.contains(&token) -} - -fn has_disallowed_o_option(value: &str) -> bool { - let lower = value.trim().to_ascii_lowercase(); - [ - "controlmaster", - "controlpath", - "controlpersist", - "batchmode", - "proxycommand", - ] - .iter() - .any(|prefix| lower.starts_with(prefix)) -} - -fn parse_ssh_command(raw: &str) -> Result { - let mut tokens = split_shell_words(raw)?; - if tokens.is_empty() { - return Err(anyhow!("SSH command is empty")); - } - - if tokens[0] == "ssh" { - tokens.remove(0); - } - - if tokens.is_empty() { - return Err(anyhow!("SSH command must include destination")); - } - - const ALLOWED_FLAGS: [&str; 11] = [ - "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y", - ]; - const ALLOWED_WITH_VALUES: [&str; 14] = [ - "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R", - ]; - - let mut destination: Option = None; - let mut args = Vec::new(); - let mut idx = 0usize; - - while idx < tokens.len() { - let token = tokens[idx].clone(); - if destination.is_some() { - return Err(anyhow!( - "SSH command has unsupported trailing argument: {token}" - )); - } - - if token.starts_with('-') { - if is_disallowed_primary_flag(token.as_str()) { - return Err(anyhow!("SSH option {token} is not allowed")); - } - - if ALLOWED_FLAGS.contains(&token.as_str()) { - args.push(token); - idx += 1; - continue; - } - - let mut matched = false; - for option in ALLOWED_WITH_VALUES { - if token == option { - if idx + 1 >= tokens.len() { - return Err(anyhow!("SSH option {option} requires a value")); - } - let value = tokens[idx + 1].clone(); - if option == "-o" && has_disallowed_o_option(&value) { - return Err(anyhow!("SSH option -o {value} is not allowed")); - } - args.push(token.clone()); - args.push(value); - idx += 2; - matched = true; - break; - } - - if token.starts_with(option) && token.len() > option.len() { - let value = token[option.len()..].to_string(); - if option == "-o" && has_disallowed_o_option(&value) { - return Err(anyhow!("SSH option -o {value} is not allowed")); - } - args.push(token.clone()); - idx += 1; - matched = true; - break; - } - } - - if !matched { - return Err(anyhow!("Unsupported SSH option: {token}")); - } - - continue; - } - - destination = Some(token); - idx += 1; - } - - let Some(destination) = destination - .map(|d| d.trim().to_string()) - .filter(|d| !d.is_empty()) - else { - return Err(anyhow!("SSH command must include destination")); - }; - - Ok(DesktopSshParsedCommand { destination, args }) -} - -fn shell_quote(value: &str) -> String { - let escaped = value.replace('\'', "'\\''"); - format!("'{escaped}'") -} - -fn run_output(command: &mut Command) -> Result<(i32, String, String)> { - let output = command - .output() - .with_context(|| format!("failed to execute command: {:?}", command))?; - - let code = output.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - Ok((code, stdout, stderr)) -} - -fn build_ssh_command( - parsed: &DesktopSshParsedCommand, - pre_destination_args: &[String], - remote_command: Option<&str>, -) -> Command { - let mut command = Command::new("ssh"); - command - .args(&parsed.args) - .args(pre_destination_args) - .arg(&parsed.destination); - if let Some(remote) = remote_command { - command.arg(remote); - } - command -} - -fn resolve_ssh_config(parsed: &DesktopSshParsedCommand) -> Result> { - let args = vec!["-G".to_string()]; - let mut command = build_ssh_command(parsed, &args, None); - let (code, stdout, stderr) = run_output(&mut command)?; - if code != 0 { - return Err(anyhow!(stderr.trim().to_string())); - } - - let mut resolved = HashMap::new(); - for line in stdout.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let mut parts = trimmed.splitn(2, ' '); - let key = parts.next().unwrap_or_default().trim().to_ascii_lowercase(); - let value = parts.next().unwrap_or_default().trim(); - if key.is_empty() || value.is_empty() { - continue; - } - resolved.insert(key, value.to_string()); - } - Ok(resolved) -} - -fn ensure_session_dir(instance_id: &str) -> Result { - let base = settings_file_path() - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("ssh") - .join(instance_id); - fs::create_dir_all(&base)?; - Ok(base) -} - -fn control_path_for_instance(_session_dir: &Path, instance_id: &str) -> PathBuf { - let hash = { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - instance_id.hash(&mut hasher); - hasher.finish() - }; - std::env::temp_dir().join(format!("ocssh-{hash:x}.sock")) -} - -fn askpass_script_content() -> String { - let script = r#"#!/bin/bash -PROMPT="$1" - -if [[ -n "$OPENCHAMBER_SSH_ASKPASS_VALUE" ]]; then - if [[ "$PROMPT" == *"assword"* || "$PROMPT" == *"passphrase"* ]]; then - printf '%s\n' "$OPENCHAMBER_SSH_ASKPASS_VALUE" - exit 0 - fi -fi - -DEFAULT_ANSWER="" -HIDDEN_INPUT="true" - -if [[ "$PROMPT" == *"yes/no"* ]]; then - DEFAULT_ANSWER="yes" - HIDDEN_INPUT="false" -fi - -/usr/bin/osascript <<'APPLESCRIPT' "$PROMPT" "$DEFAULT_ANSWER" "$HIDDEN_INPUT" -on run argv - set promptText to item 1 of argv - set defaultAnswer to item 2 of argv - set hiddenInput to item 3 of argv - - try - if hiddenInput is "true" then - set response to display dialog promptText default answer defaultAnswer with hidden answer buttons {"Cancel", "OK"} default button "OK" - else - set response to display dialog promptText default answer defaultAnswer buttons {"Cancel", "OK"} default button "OK" - end if - return text returned of response - on error - error number -128 - end try -end run -APPLESCRIPT -"#; - script.to_string() -} - -fn write_askpass_script(path: &Path) -> Result<()> { - fs::write(path, askpass_script_content())?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perm = fs::metadata(path)?.permissions(); - perm.set_mode(0o700); - fs::set_permissions(path, perm)?; - } - Ok(()) -} - -fn spawn_master_process( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - askpass_path: &Path, - ssh_password: Option<&str>, -) -> Result { - let args = vec![ - "-o".to_string(), - "ControlMaster=yes".to_string(), - "-o".to_string(), - format!("ControlPath={}", control_path.display()), - "-o".to_string(), - format!("ControlPersist={DEFAULT_CONTROL_PERSIST_SEC}"), - "-N".to_string(), - ]; - let mut command = build_ssh_command(parsed, &args, None); - command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .env("SSH_ASKPASS_REQUIRE", "force") - .env("SSH_ASKPASS", askpass_path) - .env("DISPLAY", "1"); - - if let Some(secret) = ssh_password.filter(|value| !value.trim().is_empty()) { - command.env("OPENCHAMBER_SSH_ASKPASS_VALUE", secret.trim()); - } - - command.spawn().with_context(|| { - format!( - "failed to start SSH ControlMaster for {}", - parsed.destination - ) - }) -} - -fn wait_for_master_ready( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - timeout_sec: u16, - master: &mut Child, -) -> Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(timeout_sec as u64); - let mut poll_ms: u64 = 250; - while std::time::Instant::now() < deadline { - let args = vec![ - "-o".to_string(), - "ControlMaster=no".to_string(), - "-o".to_string(), - format!("ControlPath={}", control_path.display()), - "-O".to_string(), - "check".to_string(), - ]; - - let mut check = build_ssh_command(parsed, &args, None); - let (code, _stdout, _stderr) = run_output(&mut check)?; - if code == 0 { - return Ok(()); - } - - if let Some(status) = master.try_wait().ok().flatten() { - let mut stderr = String::new(); - if let Some(mut stream) = master.stderr.take() { - let _ = stream.read_to_string(&mut stderr); - } - if stderr.trim().is_empty() { - return Err(anyhow!(format!( - "SSH master process exited before ready (status: {status})" - ))); - } - return Err(anyhow!(stderr.trim().to_string())); - } - - std::thread::sleep(Duration::from_millis(poll_ms)); - poll_ms = (poll_ms * 2).min(2000); - } - - Err(anyhow!("SSH ControlMaster connection timed out")) -} - -fn control_master_operation( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - op: &str, -) -> Result<(i32, String, String)> { - let args = vec![ - "-o".to_string(), - "ControlMaster=no".to_string(), - "-o".to_string(), - format!("ControlPath={}", control_path.display()), - "-o".to_string(), - "BatchMode=yes".to_string(), - "-o".to_string(), - "ConnectTimeout=3".to_string(), - "-O".to_string(), - op.to_string(), - ]; - let mut command = build_ssh_command(parsed, &args, None); - run_output(&mut command) -} - -fn is_control_master_alive(parsed: &DesktopSshParsedCommand, control_path: &Path) -> bool { - control_master_operation(parsed, control_path, "check") - .map(|(code, _, _)| code == 0) - .unwrap_or(false) -} - -fn stop_control_master_best_effort(parsed: &DesktopSshParsedCommand, control_path: &Path) { - let _ = control_master_operation(parsed, control_path, "exit"); -} - -fn run_remote_command( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - script: &str, - timeout_sec: u16, -) -> Result { - let args = vec![ - "-o".to_string(), - "ControlMaster=no".to_string(), - "-o".to_string(), - format!("ControlPath={}", control_path.display()), - "-o".to_string(), - format!("ConnectTimeout={timeout_sec}"), - "-T".to_string(), - ]; - let remote = format!("sh -lc {}", shell_quote(script)); - let mut command = build_ssh_command(parsed, &args, Some(&remote)); - let (code, stdout, stderr) = run_output(&mut command)?; - if code != 0 { - if stderr.trim().is_empty() { - return Err(anyhow!("Remote command failed")); - } - return Err(anyhow!(stderr.trim().to_string())); - } - Ok(stdout) -} - -fn remote_command_exists( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - command_name: &str, -) -> bool { - run_remote_command( - parsed, - control_path, - &format!( - "command -v {} >/dev/null 2>&1 && echo yes || echo no", - command_name - ), - DEFAULT_CONNECTION_TIMEOUT_SEC, - ) - .map(|output| output.trim() == "yes") - .unwrap_or(false) -} - -fn parse_version_token(raw: &str) -> Option { - for token in raw.split_whitespace() { - let mut candidate = token.trim().trim_start_matches('v').to_string(); - while candidate.ends_with(',') || candidate.ends_with(')') || candidate.ends_with('(') { - candidate.pop(); - } - let parts: Vec<&str> = candidate.split('.').collect(); - if parts.len() < 2 { - continue; - } - if parts - .iter() - .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())) - { - return Some(candidate); - } - } - None -} - -fn current_remote_openchamber_version( - parsed: &DesktopSshParsedCommand, - control_path: &Path, -) -> Option { - run_remote_command( - parsed, - control_path, - "openchamber --version 2>/dev/null || true", - DEFAULT_CONNECTION_TIMEOUT_SEC, - ) - .ok() - .and_then(|value| parse_version_token(&value)) -} - -fn install_openchamber_managed( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - version: &str, - preferred: &DesktopSshInstallMethod, -) -> Result<()> { - let has_bun = remote_command_exists(parsed, control_path, "bun"); - let has_npm = remote_command_exists(parsed, control_path, "npm"); - - let mut commands = Vec::new(); - - match preferred { - DesktopSshInstallMethod::Bun => { - if has_bun { - commands.push(format!("bun add -g @openchamber/web@{version}")); - } - if has_npm { - commands.push(format!("npm install -g @openchamber/web@{version}")); - } - } - DesktopSshInstallMethod::Npm => { - if has_npm { - commands.push(format!("npm install -g @openchamber/web@{version}")); - } - if has_bun { - commands.push(format!("bun add -g @openchamber/web@{version}")); - } - } - DesktopSshInstallMethod::DownloadRelease | DesktopSshInstallMethod::UploadBundle => { - if has_bun { - commands.push(format!("bun add -g @openchamber/web@{version}")); - } - if has_npm { - commands.push(format!("npm install -g @openchamber/web@{version}")); - } - } - } - - if commands.is_empty() { - return Err(anyhow!("Remote host has neither bun nor npm available")); - } - - let mut last_error: Option = None; - for command in commands { - match run_remote_command( - parsed, - control_path, - &command, - DEFAULT_CONNECTION_TIMEOUT_SEC, - ) { - Ok(_) => return Ok(()), - Err(err) => { - last_error = Some(err); - } - } - } - - Err(last_error.unwrap_or_else(|| anyhow!("Failed to install OpenChamber on remote host"))) -} - -fn parse_probe_status_line(line: Option<&str>, prefix: &str) -> Option { - let value = line?.strip_prefix(prefix)?.trim(); - value.parse::().ok() -} - -fn is_auth_http_status(status: u16) -> bool { - status == 401 || status == 403 -} - -fn is_liveness_http_status(status: u16) -> bool { - (200..=299).contains(&status) || is_auth_http_status(status) -} - -fn configured_openchamber_password(instance: &DesktopSshInstance) -> Option<&str> { - instance - .auth - .openchamber_password - .as_ref() - .and_then(|secret| { - if secret.enabled { - secret.value.as_deref() - } else { - None - } - }) - .map(str::trim) - .filter(|value| !value.is_empty()) -} - -fn probe_remote_system_info( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - port: u16, - openchamber_password: Option<&str>, -) -> Result { - let auth_payload = if let Some(password) = openchamber_password { - serde_json::to_string(&json!({ "password": password })).unwrap_or_else(|_| "{}".to_string()) - } else { - "{}".to_string() - }; - - let auth_enabled = if openchamber_password.is_some() { - "1" - } else { - "0" - }; - let script = format!( - "AUTH_STATUS=0; INFO_STATUS=0; HEALTH_STATUS=0; BODY_FILE=\"$(mktemp)\"; COOKIE_FILE=\"$(mktemp)\"; cleanup() {{ rm -f \"$BODY_FILE\" \"$COOKIE_FILE\"; }}; trap cleanup EXIT; if command -v curl >/dev/null 2>&1; then if [ \"{auth_enabled}\" = \"1\" ]; then AUTH_STATUS=\"$(curl -sS --max-time 3 -o /dev/null -w '%{{http_code}}' -c \"$COOKIE_FILE\" -H 'content-type: application/json' --data {auth_payload} http://127.0.0.1:{port}/auth/session || true)\"; if [ \"$AUTH_STATUS\" = \"200\" ]; then INFO_STATUS=\"$(curl -sS --max-time 3 -b \"$COOKIE_FILE\" -o \"$BODY_FILE\" -w '%{{http_code}}' http://127.0.0.1:{port}/api/system/info || true)\"; else INFO_STATUS=\"$(curl -sS --max-time 3 -o \"$BODY_FILE\" -w '%{{http_code}}' http://127.0.0.1:{port}/api/system/info || true)\"; fi; else INFO_STATUS=\"$(curl -sS --max-time 3 -o \"$BODY_FILE\" -w '%{{http_code}}' http://127.0.0.1:{port}/api/system/info || true)\"; fi; HEALTH_STATUS=\"$(curl -sS --max-time 3 -o /dev/null -w '%{{http_code}}' http://127.0.0.1:{port}/health || true)\"; elif command -v wget >/dev/null 2>&1; then wget -qO \"$BODY_FILE\" http://127.0.0.1:{port}/api/system/info >/dev/null 2>&1; if [ $? -eq 0 ]; then INFO_STATUS=200; fi; wget -qO- http://127.0.0.1:{port}/health >/dev/null 2>&1; if [ $? -eq 0 ]; then HEALTH_STATUS=200; fi; else exit 127; fi; printf 'INFO_STATUS=%s\\nAUTH_STATUS=%s\\nHEALTH_STATUS=%s\\n' \"$INFO_STATUS\" \"$AUTH_STATUS\" \"$HEALTH_STATUS\"; cat \"$BODY_FILE\" 2>/dev/null || true", - auth_payload = shell_quote(&auth_payload), - ); - let output = run_remote_command( - parsed, - control_path, - &script, - DEFAULT_CONNECTION_TIMEOUT_SEC, - )?; - - let mut lines = output.lines(); - let info_status = parse_probe_status_line(lines.next(), "INFO_STATUS=").unwrap_or(0); - let auth_status = parse_probe_status_line(lines.next(), "AUTH_STATUS=").unwrap_or(0); - let health_status = parse_probe_status_line(lines.next(), "HEALTH_STATUS=").unwrap_or(0); - let body = lines.collect::>().join("\n"); - - if is_liveness_http_status(info_status) { - if is_auth_http_status(info_status) { - if openchamber_password.is_some() && auth_status != 200 { - return Err(anyhow!(format!( - "Remote OpenChamber requires UI authentication and configured password was rejected (auth status {auth_status})" - ))); - } - - if is_liveness_http_status(health_status) { - return Ok(RemoteSystemInfo::default()); - } - - return Err(anyhow!( - "Remote OpenChamber requires UI authentication on /api/system/info; configure OpenChamber UI password" - )); - } - } else if is_liveness_http_status(health_status) { - return Ok(RemoteSystemInfo::default()); - } else { - return Err(anyhow!(format!( - "Remote OpenChamber probe failed (info status {info_status}, health status {health_status})" - ))); - } - - let mut info = serde_json::from_str::(&body).unwrap_or_default(); - if info.openchamber_version.is_none() { - if let Ok(value) = serde_json::from_str::(&body) { - info.openchamber_version = value - .get("openchamberVersion") - .and_then(Value::as_str) - .map(|v| v.to_string()); - info.runtime = value - .get("runtime") - .and_then(Value::as_str) - .map(|v| v.to_string()); - info.pid = value.get("pid").and_then(Value::as_u64); - info.started_at = value - .get("startedAt") - .and_then(Value::as_str) - .map(|v| v.to_string()); - } - } - Ok(info) -} - -fn remote_server_running( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - port: u16, - openchamber_password: Option<&str>, -) -> bool { - probe_remote_system_info(parsed, control_path, port, openchamber_password).is_ok() -} - -fn random_port_candidate(seed: &str) -> u16 { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - seed.hash(&mut hasher); - now_millis().hash(&mut hasher); - let value = hasher.finish(); - let base = 20_000u16; - let span = 30_000u16; - base + ((value % span as u64) as u16) -} - -fn start_remote_server_managed( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - instance: &DesktopSshInstance, - desired_port: u16, -) -> Result { - let mut env_prefix = "OPENCHAMBER_RUNTIME=ssh-remote".to_string(); - if let Some(secret) = instance - .auth - .openchamber_password - .as_ref() - .and_then(|v| if v.enabled { v.value.clone() } else { None }) - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - { - env_prefix.push(' '); - env_prefix.push_str("OPENCHAMBER_UI_PASSWORD="); - env_prefix.push_str(&shell_quote(&secret)); - } - let script = format!( - "{env_prefix} openchamber serve --daemon --hostname 127.0.0.1 --port {desired_port}" - ); - let output = run_remote_command( - parsed, - control_path, - &script, - DEFAULT_CONNECTION_TIMEOUT_SEC, - )?; - - if let Some(port) = output - .split_whitespace() - .find_map(|token| token.parse::().ok()) - { - return Ok(port); - } - Ok(desired_port) -} - -fn stop_remote_server_best_effort( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - remote_port: u16, -) { - let script = format!( - "if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:{remote_port}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:{remote_port}/api/system/shutdown >/dev/null 2>&1 || true; fi" - ); - let _ = run_remote_command( - parsed, - control_path, - &script, - DEFAULT_CONNECTION_TIMEOUT_SEC, - ); -} - -fn spawn_main_forward( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - bind_host: &str, - local_port: u16, - remote_port: u16, -) -> Result { - let args = vec![ - "-o".to_string(), - "ControlMaster=no".to_string(), - "-o".to_string(), - format!("ControlPath={}", control_path.display()), - "-N".to_string(), - "-L".to_string(), - format!("{bind_host}:{local_port}:127.0.0.1:{remote_port}"), - ]; - let mut command = build_ssh_command(parsed, &args, None); - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .with_context(|| format!("Failed to start main SSH forward on local port {local_port}")) -} - -fn spawn_extra_forward( - parsed: &DesktopSshParsedCommand, - control_path: &Path, - forward: &DesktopSshPortForward, -) -> Result<()> { - let mut args = vec![ - "-o".to_string(), - "ControlMaster=no".to_string(), - "-o".to_string(), - format!("ControlPath={}", control_path.display()), - "-O".to_string(), - "forward".to_string(), - ]; - - match forward.forward_type { - DesktopSshPortForwardType::Local => { - let local_host = forward - .local_host - .as_deref() - .filter(|v| !v.trim().is_empty()) - .unwrap_or("127.0.0.1"); - let local_port = forward - .local_port - .ok_or_else(|| anyhow!("Missing local port"))?; - let remote_host = forward - .remote_host - .as_deref() - .filter(|v| !v.trim().is_empty()) - .unwrap_or("127.0.0.1"); - let remote_port = forward - .remote_port - .ok_or_else(|| anyhow!("Missing remote port"))?; - args.push("-L".to_string()); - args.push(format!( - "{local_host}:{local_port}:{remote_host}:{remote_port}" - )); - } - DesktopSshPortForwardType::Remote => { - let remote_host = forward - .remote_host - .as_deref() - .filter(|v| !v.trim().is_empty()) - .unwrap_or("127.0.0.1"); - let remote_port = forward - .remote_port - .ok_or_else(|| anyhow!("Missing remote port"))?; - let local_host = forward - .local_host - .as_deref() - .filter(|v| !v.trim().is_empty()) - .unwrap_or("127.0.0.1"); - let local_port = forward - .local_port - .ok_or_else(|| anyhow!("Missing local port"))?; - args.push("-R".to_string()); - args.push(format!( - "{remote_host}:{remote_port}:{local_host}:{local_port}" - )); - } - DesktopSshPortForwardType::Dynamic => { - let local_host = forward - .local_host - .as_deref() - .filter(|v| !v.trim().is_empty()) - .unwrap_or("127.0.0.1"); - let local_port = forward - .local_port - .ok_or_else(|| anyhow!("Missing local port"))?; - args.push("-D".to_string()); - args.push(format!("{local_host}:{local_port}")); - } - } - - let mut command = build_ssh_command(parsed, &args, None); - let (code, stdout, stderr) = run_output(&mut command) - .with_context(|| format!("Failed to configure extra SSH forward {}", forward.id))?; - if code != 0 { - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - return Err(anyhow!(format!( - "Failed to configure extra SSH forward {}: {}", - forward.id, - if detail.is_empty() { - "unknown error" - } else { - detail - } - ))); - } - Ok(()) -} - -fn is_local_port_available(bind_host: &str, port: u16) -> bool { - TcpListener::bind(format!("{bind_host}:{port}")).is_ok() -} - -fn pick_unused_local_port() -> Result { - let listener = TcpListener::bind("127.0.0.1:0")?; - Ok(listener.local_addr()?.port()) -} - -fn is_local_tunnel_reachable(local_port: u16) -> bool { - let addr = format!("127.0.0.1:{local_port}"); - let Ok(parsed) = addr.parse() else { - return false; - }; - TcpStream::connect_timeout(&parsed, Duration::from_millis(500)).is_ok() -} - -fn wait_local_forward_ready(local_port: u16) -> Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(DEFAULT_READY_TIMEOUT_SEC); - let addr: std::net::SocketAddr = format!("127.0.0.1:{local_port}").parse()?; - let mut poll_ms: u64 = 250; - while std::time::Instant::now() < deadline { - if let Ok(mut stream) = - TcpStream::connect_timeout(&addr, Duration::from_millis(1000)) - { - use std::io::{Read as IoRead, Write}; - let _ = stream.set_read_timeout(Some(Duration::from_millis(1000))); - let _ = stream.set_write_timeout(Some(Duration::from_millis(1000))); - let request = format!( - "GET /health HTTP/1.1\r\nHost: 127.0.0.1:{local_port}\r\nConnection: close\r\n\r\n" - ); - if stream.write_all(request.as_bytes()).is_ok() { - let mut buf = [0u8; 32]; - if let Ok(n) = stream.read(&mut buf) { - let head = std::str::from_utf8(&buf[..n]).unwrap_or(""); - // Match "HTTP/1.x 2xx" or "HTTP/1.x 401" - if head.starts_with("HTTP/1.") - && (head.contains(" 2") || head.contains(" 401")) - { - return Ok(()); - } - } - } - } - std::thread::sleep(Duration::from_millis(poll_ms)); - poll_ms = (poll_ms * 2).min(2000); - } - Err(anyhow!( - "Timed out waiting for forwarded OpenChamber health" - )) -} - -fn kill_child(child: &mut Child) { - let _ = child.kill(); - let _ = child.wait(); -} - -fn parse_ssh_config_candidates(path: &Path, source: &str) -> Vec { - let Ok(content) = fs::read_to_string(path) else { - return Vec::new(); - }; - let mut candidates = Vec::new(); - for line in content.lines() { - let trimmed = line.split('#').next().map(|part| part.trim()).unwrap_or(""); - if trimmed.is_empty() { - continue; - } - if trimmed.len() < 4 { - continue; - } - if !trimmed[..4].eq_ignore_ascii_case("host") { - continue; - } - - let rest = trimmed[4..].trim(); - if rest.is_empty() { - continue; - } - - for token in rest.split_whitespace() { - let host = token.trim(); - if host.is_empty() || host.starts_with('!') { - continue; - } - if host == "*" { - continue; - } - let pattern = host.contains('*') || host.contains('?'); - candidates.push(DesktopSshImportCandidate { - host: host.to_string(), - pattern, - source: source.to_string(), - ssh_command: format!("ssh {host}"), - }); - } - } - candidates -} - -impl DesktopSshManagerInner { - fn append_log_with_level(&self, id: &str, level: &str, message: impl Into) { - let line = format!("[{}] [{}] {}", now_millis(), level, message.into()); - let mut logs = self.logs.lock().expect("ssh logs mutex"); - let entry = logs.entry(id.to_string()).or_default(); - entry.push(line); - if entry.len() > MAX_LOG_LINES_PER_INSTANCE { - let overflow = entry.len() - MAX_LOG_LINES_PER_INSTANCE; - entry.drain(0..overflow); - } - } - - fn append_log(&self, id: &str, message: impl Into) { - self.append_log_with_level(id, "INFO", message); - } - - fn append_attempt_separator(&self, id: &str, connect_attempt: u32, retry_attempt: u32) { - let scope = if retry_attempt > 0 { - format!("retry {retry_attempt}") - } else { - "manual".to_string() - }; - self.append_log_with_level( - id, - "INFO", - format!("---------------- attempt #{connect_attempt} ({scope}) ----------------"), - ); - } - - fn logs_for_instance(&self, id: &str, limit: usize) -> Vec { - let logs = self.logs.lock().expect("ssh logs mutex"); - let mut lines = logs.get(id).cloned().unwrap_or_default(); - if limit > 0 && lines.len() > limit { - let keep_from = lines.len() - limit; - lines.drain(0..keep_from); - } - lines - } - - fn clear_logs_for_instance(&self, id: &str) { - self.logs.lock().expect("ssh logs mutex").remove(id); - } - - fn status_snapshot_for_instance(&self, id: &str) -> DesktopSshInstanceStatus { - self.statuses - .lock() - .expect("ssh status mutex") - .get(id) - .cloned() - .unwrap_or_else(|| DesktopSshInstanceStatus::idle(id)) - } - - fn set_status( - &self, - app: &AppHandle, - id: &str, - phase: DesktopSshPhase, - detail: Option, - local_url: Option, - local_port: Option, - remote_port: Option, - started_by_us: bool, - retry_attempt: u32, - requires_user_action: bool, - ) { - let level = if matches!(&phase, DesktopSshPhase::Error) { - "ERROR" - } else if matches!(&phase, DesktopSshPhase::Degraded) { - "WARN" - } else { - "INFO" - }; - - self.append_log_with_level( - id, - level, - format!( - "phase={} detail={} retry={} requires_user_action={}", - serde_json::to_string(&phase).unwrap_or_else(|_| "\"unknown\"".to_string()), - detail.as_deref().unwrap_or(""), - retry_attempt, - requires_user_action - ), - ); - - let status = DesktopSshInstanceStatus { - id: id.to_string(), - phase, - detail, - local_url, - local_port, - remote_port, - started_by_us, - retry_attempt, - requires_user_action, - updated_at_ms: now_millis(), - }; - - self.statuses - .lock() - .expect("ssh status mutex") - .insert(id.to_string(), status.clone()); - let _ = app.emit(SSH_STATUS_EVENT, status); - } - - fn clear_retry_attempt(&self, id: &str) { - self.reconnect_attempts - .lock() - .expect("ssh retry mutex") - .remove(id); - } - - fn next_retry_attempt(&self, id: &str) -> u32 { - let mut guard = self.reconnect_attempts.lock().expect("ssh retry mutex"); - let next = guard.get(id).copied().unwrap_or(0).saturating_add(1); - guard.insert(id.to_string(), next); - next - } - - fn current_retry_attempt(&self, id: &str) -> u32 { - self.reconnect_attempts - .lock() - .expect("ssh retry mutex") - .get(id) - .copied() - .unwrap_or(0) - } - - fn next_connect_attempt(&self, id: &str) -> u32 { - let mut guard = self - .connect_attempts - .lock() - .expect("ssh connect-attempt mutex"); - let next = guard.get(id).copied().unwrap_or(0).saturating_add(1); - guard.insert(id.to_string(), next); - next - } - - fn cancel_connect_task(&self, id: &str) { - if let Some(handle) = self - .connect_tasks - .lock() - .expect("ssh connect task mutex") - .remove(id) - { - handle.abort(); - } - } - - fn cancel_monitor_task(&self, id: &str) { - if let Some(handle) = self - .monitor_tasks - .lock() - .expect("ssh monitor task mutex") - .remove(id) - { - handle.abort(); - } - } - - fn session_is_alive(&self, id: &str) -> bool { - let mut sessions = self.sessions.lock().expect("ssh sessions mutex"); - let Some(session) = sessions.get_mut(id) else { - return false; - }; - - let mut main_anchor_alive = false; - - if !session.main_forward_detached { - if let Some(status) = session.main_forward.try_wait().ok().flatten() { - if status.success() { - session.main_forward_detached = true; - self.append_log_with_level( - id, - "INFO", - "Main tunnel helper exited after ControlMaster handoff", - ); - } else { - let mut stderr = String::new(); - if let Some(mut stream) = session.main_forward.stderr.take() { - let _ = stream.read_to_string(&mut stderr); - } - self.append_log_with_level( - id, - "WARN", - if stderr.trim().is_empty() { - format!("Existing main SSH forward is not running ({status})") - } else { - format!( - "Existing main SSH forward is not running ({status}): {}", - stderr.trim() - ) - }, - ); - return false; - } - } else { - main_anchor_alive = true; - } - } - - if main_anchor_alive { - return true; - } - - if session.master_detached { - if !is_control_master_alive(&session.parsed, &session.control_path) { - if is_local_tunnel_reachable(session.local_port) { - self.append_log_with_level( - id, - "WARN", - "SSH ControlMaster check failed but local tunnel is still reachable", - ); - return true; - } - self.append_log_with_level( - id, - "WARN", - "Existing SSH ControlMaster is not reachable", - ); - return false; - } - } else if let Some(status) = session.master.try_wait().ok().flatten() { - if status.success() && is_control_master_alive(&session.parsed, &session.control_path) { - session.master_detached = true; - self.append_log_with_level( - id, - "INFO", - "SSH ControlMaster transitioned to detached background mode", - ); - } else { - let mut stderr = String::new(); - if let Some(mut stream) = session.master.stderr.take() { - let _ = stream.read_to_string(&mut stderr); - } - self.append_log_with_level( - id, - "WARN", - if stderr.trim().is_empty() { - format!("Existing SSH ControlMaster is not running ({status})") - } else { - format!( - "Existing SSH ControlMaster is not running ({status}): {}", - stderr.trim() - ) - }, - ); - return false; - } - } - - true - } - - fn disconnect_internal(&self, app: &AppHandle, id: &str, report_idle: bool) { - self.cancel_connect_task(id); - self.cancel_monitor_task(id); - - if let Some(mut session) = self.sessions.lock().expect("ssh sessions mutex").remove(id) { - if session.started_by_us - && matches!( - session.instance.remote_openchamber.mode, - DesktopSshRemoteMode::Managed - ) - && !session.instance.remote_openchamber.keep_running - { - stop_remote_server_best_effort( - &session.parsed, - &session.control_path, - session.remote_port, - ); - } - - stop_control_master_best_effort(&session.parsed, &session.control_path); - - kill_child(&mut session.main_forward); - for child in &mut session.extra_forwards { - kill_child(child); - } - kill_child(&mut session.master); - - let _ = fs::remove_file(&session.control_path); - let _ = fs::remove_file(session.session_dir.join("askpass.sh")); - } - - self.clear_retry_attempt(id); - - if report_idle { - self.set_status( - app, - id, - DesktopSshPhase::Idle, - None, - None, - None, - None, - false, - 0, - false, - ); - } - } - - fn ensure_remote_server( - &self, - app: &AppHandle, - instance: &DesktopSshInstance, - parsed: &DesktopSshParsedCommand, - control_path: &Path, - ) -> Result<(u16, bool)> { - let app_version = app.package_info().version.to_string(); - - match instance.remote_openchamber.mode { - DesktopSshRemoteMode::External => { - let Some(port) = instance.remote_openchamber.preferred_port else { - return Err(anyhow!( - "External mode requires a preferred remote OpenChamber port" - )); - }; - self.set_status( - app, - &instance.id, - DesktopSshPhase::ServerDetecting, - Some("Probing external OpenChamber server".to_string()), - None, - None, - Some(port), - false, - 0, - false, - ); - probe_remote_system_info( - parsed, - control_path, - port, - configured_openchamber_password(instance), - ) - .map_err(|err| { - anyhow!(format!( - "External OpenChamber server probe failed on configured remote port: {err}" - )) - })?; - Ok((port, false)) - } - DesktopSshRemoteMode::Managed => { - self.set_status( - app, - &instance.id, - DesktopSshPhase::RemoteProbe, - Some("Checking remote OpenChamber installation".to_string()), - None, - None, - None, - false, - 0, - false, - ); - - let installed_version = current_remote_openchamber_version(parsed, control_path); - if installed_version.is_none() { - self.set_status( - app, - &instance.id, - DesktopSshPhase::Installing, - Some("Installing OpenChamber on remote host".to_string()), - None, - None, - None, - false, - 0, - false, - ); - install_openchamber_managed( - parsed, - control_path, - &app_version, - &instance.remote_openchamber.install_method, - )?; - } else if installed_version.as_deref() != Some(app_version.as_str()) { - self.set_status( - app, - &instance.id, - DesktopSshPhase::Updating, - Some(format!( - "Updating remote OpenChamber from {} to {}", - installed_version - .clone() - .unwrap_or_else(|| "unknown".to_string()), - app_version - )), - None, - None, - None, - false, - 0, - false, - ); - install_openchamber_managed( - parsed, - control_path, - &app_version, - &instance.remote_openchamber.install_method, - )?; - } - - self.set_status( - app, - &instance.id, - DesktopSshPhase::ServerDetecting, - Some("Detecting managed OpenChamber server".to_string()), - None, - None, - None, - false, - 0, - false, - ); - - let mut started_by_us = false; - let mut remote_port = instance.remote_openchamber.preferred_port; - - if let Some(port) = remote_port { - if !remote_server_running( - parsed, - control_path, - port, - configured_openchamber_password(instance), - ) { - remote_port = None; - } - } - - if remote_port.is_none() { - self.set_status( - app, - &instance.id, - DesktopSshPhase::ServerStarting, - Some("Starting managed OpenChamber server".to_string()), - None, - None, - None, - false, - 0, - false, - ); - let desired_port = instance - .remote_openchamber - .preferred_port - .unwrap_or_else(|| random_port_candidate(&instance.id)); - let started_port = - start_remote_server_managed(parsed, control_path, instance, desired_port)?; - remote_port = Some(started_port); - started_by_us = true; - } - - let Some(port) = remote_port else { - return Err(anyhow!("Failed to determine remote OpenChamber port")); - }; - - if !remote_server_running( - parsed, - control_path, - port, - configured_openchamber_password(instance), - ) { - return Err(anyhow!( - "Managed OpenChamber server failed to become reachable" - )); - } - - Ok((port, started_by_us)) - } - } - } - - fn connect_blocking( - self: &Arc, - app: &AppHandle, - instance: DesktopSshInstance, - ) -> Result<()> { - let id = instance.id.clone(); - self.set_status( - app, - &id, - DesktopSshPhase::ConfigResolved, - Some("Resolving SSH command".to_string()), - None, - None, - None, - false, - 0, - false, - ); - - let parsed = instance - .ssh_parsed - .clone() - .or_else(|| parse_ssh_command(&instance.ssh_command).ok()) - .ok_or_else(|| anyhow!("Invalid SSH command"))?; - - let _resolved = resolve_ssh_config(&parsed)?; - - self.set_status( - app, - &id, - DesktopSshPhase::AuthCheck, - Some("Checking SSH connectivity".to_string()), - None, - None, - None, - false, - 0, - false, - ); - - let session_dir = ensure_session_dir(&id)?; - let control_path = control_path_for_instance(&session_dir, &id); - let _ = fs::remove_file(&control_path); - let askpass_path = session_dir.join("askpass.sh"); - write_askpass_script(&askpass_path)?; - - self.set_status( - app, - &id, - DesktopSshPhase::MasterConnecting, - Some("Establishing SSH ControlMaster".to_string()), - None, - None, - None, - false, - 0, - false, - ); - - let mut master = spawn_master_process( - &parsed, - &control_path, - &askpass_path, - instance.auth.ssh_password.as_ref().and_then(|secret| { - if secret.enabled { - secret.value.as_deref() - } else { - None - } - }), - )?; - - if let Err(err) = wait_for_master_ready( - &parsed, - &control_path, - instance.connection_timeout_sec, - &mut master, - ) { - kill_child(&mut master); - return Err(err); - } - - self.set_status( - app, - &id, - DesktopSshPhase::RemoteProbe, - Some("Probing remote platform".to_string()), - None, - None, - None, - false, - 0, - false, - ); - - let remote_os = run_remote_command( - &parsed, - &control_path, - "uname -s", - instance.connection_timeout_sec, - )?; - - let remote_os = remote_os.trim().to_ascii_lowercase(); - if remote_os != "linux" && remote_os != "darwin" { - kill_child(&mut master); - return Err(anyhow!("Unsupported remote OS: {remote_os}")); - } - - let (remote_port, started_by_us) = - match self.ensure_remote_server(app, &instance, &parsed, &control_path) { - Ok(result) => result, - Err(err) => { - kill_child(&mut master); - return Err(err); - } - }; - - self.set_status( - app, - &id, - DesktopSshPhase::Forwarding, - Some("Setting up port forwards".to_string()), - None, - None, - Some(remote_port), - started_by_us, - 0, - false, - ); - - let bind_host = sanitize_bind_host(&instance.local_forward.bind_host); - let mut local_port = instance.local_forward.preferred_local_port.unwrap_or(0); - if local_port == 0 { - local_port = pick_unused_local_port()?; - } - if !is_local_port_available(&bind_host, local_port) { - local_port = pick_unused_local_port()?; - } - - let mut main_forward = - match spawn_main_forward(&parsed, &control_path, &bind_host, local_port, remote_port) { - Ok(child) => child, - Err(err) => { - kill_child(&mut master); - return Err(err); - } - }; - let mut main_forward_detached = false; - - std::thread::sleep(Duration::from_millis(250)); - if let Some(status) = main_forward.try_wait().ok().flatten() { - if status.success() { - main_forward_detached = true; - self.append_log_with_level( - &id, - "INFO", - "Main tunnel helper exited after ControlMaster handoff", - ); - } else { - let mut stderr = String::new(); - if let Some(mut stream) = main_forward.stderr.take() { - let _ = stream.read_to_string(&mut stderr); - } - kill_child(&mut master); - return Err(anyhow!(format!( - "Failed to start main port forward (status: {status}): {}", - stderr.trim() - ))); - } - } - - let mut extra_forwards = Vec::new(); - let mut extra_errors = Vec::new(); - for forward in instance - .port_forwards - .iter() - .filter(|forward| forward.enabled) - { - match spawn_extra_forward(&parsed, &control_path, forward) { - Ok(()) => { - if matches!(forward.forward_type, DesktopSshPortForwardType::Local) { - if let Some(local_port) = forward.local_port { - std::thread::sleep(Duration::from_millis(100)); - if !is_local_tunnel_reachable(local_port) { - extra_errors.push(format!( - "{}: local listener 127.0.0.1:{} is not reachable", - forward.id, local_port - )); - } - } - } - } - Err(err) => extra_errors.push(format!("{}: {}", forward.id, err)), - } - } - - if let Err(err) = wait_local_forward_ready(local_port) { - kill_child(&mut main_forward); - for child in &mut extra_forwards { - kill_child(child); - } - kill_child(&mut master); - return Err(err); - } - - let local_url = format!("http://127.0.0.1:{local_port}"); - let label = build_display_label(&instance); - let _ = update_ssh_host_url(&id, &label, &local_url); - if instance.local_forward.preferred_local_port != Some(local_port) { - let _ = persist_local_port_for_instance(&id, local_port); - } - - self.sessions.lock().expect("ssh sessions mutex").insert( - id.clone(), - SshSession { - instance: instance.clone(), - parsed, - session_dir, - control_path, - local_port, - remote_port, - started_by_us, - master, - master_detached: false, - main_forward, - main_forward_detached, - extra_forwards, - }, - ); - - self.clear_retry_attempt(&id); - self.set_status( - app, - &id, - DesktopSshPhase::Ready, - if extra_errors.is_empty() { - Some("SSH instance is ready".to_string()) - } else { - Some(format!( - "SSH instance is ready with forward warnings: {}", - extra_errors.join("; ") - )) - }, - Some(local_url), - Some(local_port), - Some(remote_port), - started_by_us, - 0, - false, - ); - - self.spawn_monitor(app.clone(), id); - Ok(()) - } - - fn spawn_monitor(self: &Arc, app: AppHandle, id: String) { - self.cancel_monitor_task(&id); - let inner = Arc::clone(self); - let id_for_task = id.clone(); - let handle = tauri::async_runtime::spawn(async move { - let mut healthy_ticks: u32 = 0; - loop { - let poll_secs = if healthy_ticks >= MONITOR_STABILIZE_TICKS { - MONITOR_STEADY_POLL_SECS - } else { - MONITOR_INITIAL_POLL_SECS - }; - tokio::time::sleep(Duration::from_secs(poll_secs)).await; - - let mut dropped_reason: Option = None; - let mut detached_notice: Option = None; - { - let mut sessions = inner.sessions.lock().expect("ssh sessions mutex"); - let Some(session) = sessions.get_mut(&id_for_task) else { - break; - }; - - let mut main_anchor_alive = false; - - if !session.main_forward_detached { - if let Some(status) = session.main_forward.try_wait().ok().flatten() { - if status.success() { - session.main_forward_detached = true; - detached_notice = Some( - "Main tunnel helper exited after ControlMaster handoff" - .to_string(), - ); - } else { - let mut stderr = String::new(); - if let Some(mut stream) = session.main_forward.stderr.take() { - let _ = stream.read_to_string(&mut stderr); - } - dropped_reason = Some(if stderr.trim().is_empty() { - format!("Main SSH forward exited ({status})") - } else { - format!("Main SSH forward exited ({status}): {}", stderr.trim()) - }); - } - } else { - main_anchor_alive = true; - } - } - - if dropped_reason.is_none() { - if main_anchor_alive { - if !session.master_detached { - if let Some(status) = session.master.try_wait().ok().flatten() { - if status.success() - && is_control_master_alive( - &session.parsed, - &session.control_path, - ) - { - session.master_detached = true; - if detached_notice.is_none() { - detached_notice = Some( - "SSH ControlMaster transitioned to detached background mode" - .to_string(), - ); - } - } else { - detached_notice = Some( - "SSH ControlMaster exited while main tunnel is still active" - .to_string(), - ); - } - } - } else if !is_control_master_alive( - &session.parsed, - &session.control_path, - ) { - detached_notice = Some( - "SSH ControlMaster is not reachable; main tunnel remains active" - .to_string(), - ); - } - } else if session.master_detached { - // Fast path: check local tunnel first (cheap TCP probe) - // before spawning an SSH subprocess for control master check. - if is_local_tunnel_reachable(session.local_port) { - // Tunnel is alive — skip the expensive SSH check entirely. - } else if !is_control_master_alive( - &session.parsed, - &session.control_path, - ) { - dropped_reason = - Some("SSH ControlMaster is not reachable".to_string()); - } else { - detached_notice = Some( - "Local tunnel unreachable but ControlMaster is alive" - .to_string(), - ); - } - } else if let Some(status) = session.master.try_wait().ok().flatten() { - if status.success() - && is_control_master_alive(&session.parsed, &session.control_path) - { - session.master_detached = true; - if detached_notice.is_none() { - detached_notice = Some( - "SSH ControlMaster transitioned to detached background mode" - .to_string(), - ); - } - } else { - let mut stderr = String::new(); - if let Some(mut stream) = session.master.stderr.take() { - let _ = stream.read_to_string(&mut stderr); - } - dropped_reason = Some(if stderr.trim().is_empty() { - format!("SSH ControlMaster exited ({status})") - } else { - format!( - "SSH ControlMaster exited ({status}): {}", - stderr.trim() - ) - }); - } - } - } - } - - if let Some(message) = detached_notice { - inner.append_log_with_level(&id_for_task, "INFO", message); - } - - if dropped_reason.is_none() { - healthy_ticks = healthy_ticks.saturating_add(1); - continue; - } - - let dropped_reason = - dropped_reason.unwrap_or_else(|| "SSH connection dropped".to_string()); - inner.append_log_with_level(&id_for_task, "WARN", dropped_reason.clone()); - - inner.disconnect_internal(&app, &id_for_task, false); - let attempt = inner.next_retry_attempt(&id_for_task); - - if attempt > DEFAULT_RECONNECT_MAX_ATTEMPTS { - inner.set_status( - &app, - &id_for_task, - DesktopSshPhase::Error, - Some(format!("{dropped_reason}. Retry limit reached")), - None, - None, - None, - false, - attempt, - true, - ); - break; - } - - inner.set_status( - &app, - &id_for_task, - DesktopSshPhase::Degraded, - Some(format!("{dropped_reason}. Reconnecting")), - None, - None, - None, - false, - attempt, - false, - ); - - let delay_ms = - (2u64.saturating_pow(attempt.saturating_sub(1))).saturating_mul(1000); - let jitter = (now_millis() % 700).saturating_add(100); - tokio::time::sleep(Duration::from_millis( - delay_ms.min(30_000).saturating_add(jitter), - )) - .await; - - if let Err(err) = inner.start_connect(app.clone(), id_for_task.clone()) { - inner.set_status( - &app, - &id_for_task, - DesktopSshPhase::Error, - Some(err), - None, - None, - None, - false, - attempt, - true, - ); - } - break; - } - - inner - .monitor_tasks - .lock() - .expect("ssh monitor task mutex") - .remove(&id_for_task); - }); - self.monitor_tasks - .lock() - .expect("ssh monitor task mutex") - .insert(id, handle); - } - - fn start_connect(self: &Arc, app: AppHandle, id: String) -> Result<(), String> { - let config = read_desktop_ssh_instances_from_disk(); - let Some(instance) = config.instances.into_iter().find(|item| item.id == id) else { - return Err("SSH instance not found".to_string()); - }; - - if self - .connect_tasks - .lock() - .expect("ssh connect task mutex") - .contains_key(&id) - { - self.append_log_with_level(&id, "INFO", "Connection already in progress"); - return Ok(()); - } - - if self.session_is_alive(&id) { - let snapshot = self.status_snapshot_for_instance(&id); - self.set_status( - &app, - &id, - DesktopSshPhase::Ready, - Some("SSH session already active".to_string()), - snapshot.local_url, - snapshot.local_port, - snapshot.remote_port, - snapshot.started_by_us, - snapshot.retry_attempt, - false, - ); - self.append_log_with_level( - &id, - "INFO", - "Connection already active; reusing existing SSH session", - ); - return Ok(()); - } - - let retry_attempt = self.current_retry_attempt(&id); - let connect_attempt = self.next_connect_attempt(&id); - self.append_attempt_separator(&id, connect_attempt, retry_attempt); - self.append_log(&id, "Starting SSH connection"); - self.disconnect_internal(&app, &id, false); - - let id_for_task = id.clone(); - let inner = Arc::clone(self); - let app_for_task = app.clone(); - let handle = tauri::async_runtime::spawn(async move { - let result = tauri::async_runtime::spawn_blocking({ - let inner = Arc::clone(&inner); - let app = app_for_task.clone(); - let instance = instance.clone(); - move || inner.connect_blocking(&app, instance) - }) - .await; - - match result { - Ok(Ok(())) => {} - Ok(Err(err)) => { - inner.set_status( - &app_for_task, - &id_for_task, - DesktopSshPhase::Error, - Some(err.to_string()), - None, - None, - None, - false, - 0, - true, - ); - inner.disconnect_internal(&app_for_task, &id_for_task, false); - } - Err(err) => { - inner.set_status( - &app_for_task, - &id_for_task, - DesktopSshPhase::Error, - Some(format!("SSH task failed: {err}")), - None, - None, - None, - false, - 0, - true, - ); - inner.disconnect_internal(&app_for_task, &id_for_task, false); - } - } - - inner - .connect_tasks - .lock() - .expect("ssh connect task mutex") - .remove(&id_for_task); - }); - - self.connect_tasks - .lock() - .expect("ssh connect task mutex") - .insert(id, handle); - - Ok(()) - } - - fn statuses_with_defaults(&self) -> Vec { - let config = read_desktop_ssh_instances_from_disk(); - let statuses = self.statuses.lock().expect("ssh status mutex"); - let mut result = Vec::new(); - - for instance in config.instances { - result.push( - statuses - .get(&instance.id) - .cloned() - .unwrap_or_else(|| DesktopSshInstanceStatus::idle(instance.id)), - ); - } - - result.sort_by(|a, b| a.id.cmp(&b.id)); - result - } -} - -#[tauri::command] -pub fn desktop_ssh_logs( - state: State<'_, DesktopSshManagerState>, - id: String, - limit: Option, -) -> Result, String> { - let id = id.trim().to_string(); - if id.is_empty() || id == LOCAL_HOST_ID { - return Err("SSH instance id is required".to_string()); - } - let cap = limit.unwrap_or(200).min(MAX_LOG_LINES_PER_INSTANCE); - Ok(state.inner.logs_for_instance(&id, cap)) -} - -#[tauri::command] -pub fn desktop_ssh_logs_clear( - state: State<'_, DesktopSshManagerState>, - id: String, -) -> Result<(), String> { - let id = id.trim().to_string(); - if id.is_empty() || id == LOCAL_HOST_ID { - return Err("SSH instance id is required".to_string()); - } - state.inner.clear_logs_for_instance(&id); - Ok(()) -} - -impl DesktopSshManagerState { - pub fn shutdown_all(&self, app: &AppHandle) { - let ids: Vec = self - .inner - .sessions - .lock() - .expect("ssh sessions mutex") - .keys() - .cloned() - .collect(); - for id in ids { - self.inner.disconnect_internal(app, &id, false); - } - - let connect_ids: Vec = self - .inner - .connect_tasks - .lock() - .expect("ssh connect task mutex") - .keys() - .cloned() - .collect(); - for id in connect_ids { - self.inner.cancel_connect_task(&id); - } - - let monitor_ids: Vec = self - .inner - .monitor_tasks - .lock() - .expect("ssh monitor task mutex") - .keys() - .cloned() - .collect(); - for id in monitor_ids { - self.inner.cancel_monitor_task(&id); - } - } -} - -#[tauri::command] -pub fn desktop_ssh_instances_get() -> Result { - Ok(read_desktop_ssh_instances_from_disk()) -} - -#[tauri::command] -pub fn desktop_ssh_instances_set(config: DesktopSshInstancesConfig) -> Result<(), String> { - write_desktop_ssh_instances_to_path(&settings_file_path(), config) - .map(|_| ()) - .map_err(|err| err.to_string()) -} - -#[tauri::command] -pub fn desktop_ssh_import_hosts() -> Result, String> { - let mut candidates = Vec::new(); - - if let Some(home) = std::env::var_os("HOME") { - let user_config = PathBuf::from(home).join(".ssh").join("config"); - candidates.extend(parse_ssh_config_candidates(&user_config, "user")); - } - candidates.extend(parse_ssh_config_candidates( - Path::new("/etc/ssh/ssh_config"), - "global", - )); - - let mut seen = HashSet::new(); - candidates.retain(|item| seen.insert(item.host.clone())); - candidates.sort_by(|a, b| a.host.cmp(&b.host)); - Ok(candidates) -} - -#[tauri::command] -pub fn desktop_ssh_connect( - app: AppHandle, - state: State<'_, DesktopSshManagerState>, - id: String, -) -> Result<(), String> { - let id = id.trim().to_string(); - if id.is_empty() || id == LOCAL_HOST_ID { - return Err("SSH instance id is required".to_string()); - } - state.inner.start_connect(app, id) -} - -#[tauri::command] -pub fn desktop_ssh_disconnect( - app: AppHandle, - state: State<'_, DesktopSshManagerState>, - id: String, -) -> Result<(), String> { - let id = id.trim().to_string(); - if id.is_empty() || id == LOCAL_HOST_ID { - return Err("SSH instance id is required".to_string()); - } - state.inner.disconnect_internal(&app, &id, true); - Ok(()) -} - -#[tauri::command] -pub fn desktop_ssh_status( - state: State<'_, DesktopSshManagerState>, - id: Option, -) -> Result, String> { - if let Some(instance_id) = id - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - { - return Ok(vec![state.inner.status_snapshot_for_instance(&instance_id)]); - } - - Ok(state.inner.statuses_with_defaults()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_instance(id: &str, command: &str) -> DesktopSshInstance { - DesktopSshInstance { - id: id.to_string(), - nickname: None, - ssh_command: command.to_string(), - ssh_parsed: None, - connection_timeout_sec: DEFAULT_CONNECTION_TIMEOUT_SEC, - remote_openchamber: DesktopSshRemoteOpenchamberConfig::default(), - local_forward: DesktopSshLocalForwardConfig::default(), - auth: DesktopSshAuthConfig::default(), - port_forwards: Vec::new(), - } - } - - #[test] - fn parse_ssh_command_accepts_supported_options() { - let parsed = parse_ssh_command( - "ssh -J jump.example.com -o StrictHostKeyChecking=accept-new user@example.com", - ) - .expect("parsed"); - assert_eq!(parsed.destination, "user@example.com"); - assert_eq!( - parsed.args, - vec![ - "-J".to_string(), - "jump.example.com".to_string(), - "-o".to_string(), - "StrictHostKeyChecking=accept-new".to_string(), - ] - ); - } - - #[test] - fn parse_ssh_command_rejects_disallowed_flags() { - let err = parse_ssh_command("ssh -M user@example.com") - .expect_err("should reject control master flag"); - assert!(err.to_string().contains("not allowed")); - } - - #[test] - fn parse_ssh_command_rejects_disallowed_controlpath_option() { - let err = parse_ssh_command("ssh -o ControlPath=/tmp/ssh.sock user@example.com") - .expect_err("should reject controlpath override"); - assert!(err.to_string().contains("not allowed")); - } - - #[test] - fn parse_ssh_command_keeps_ipv6_destination() { - let parsed = - parse_ssh_command("ssh user@[2001:db8::1]:2222").expect("parsed ipv6 destination"); - assert_eq!(parsed.destination, "user@[2001:db8::1]:2222"); - } - - #[test] - fn sync_desktop_hosts_removes_deleted_ssh_hosts() { - let mut root = json!({ - "desktopHosts": [ - {"id": "ssh-old", "label": "Old", "url": "http://127.0.0.1:1"}, - {"id": "http-1", "label": "HTTP", "url": "https://example.com"} - ], - "desktopDefaultHostId": "ssh-old" - }); - - let mut previous = HashSet::new(); - previous.insert("ssh-old".to_string()); - - let next = vec![sample_instance("ssh-new", "ssh user@example.com")]; - sync_desktop_hosts_for_ssh(&mut root, &previous, &next); - - let hosts = root - .get("desktopHosts") - .and_then(Value::as_array) - .expect("hosts array"); - assert_eq!(hosts.len(), 2); - assert!(hosts - .iter() - .any(|item| item.get("id") == Some(&Value::String("http-1".to_string())))); - assert!(hosts - .iter() - .any(|item| item.get("id") == Some(&Value::String("ssh-new".to_string())))); - assert_eq!( - root.get("desktopDefaultHostId").and_then(Value::as_str), - Some("local") - ); - } - - #[test] - fn parse_ssh_config_candidates_extracts_host_entries() { - let temp = - std::env::temp_dir().join(format!("openchamber-ssh-import-{}.txt", now_millis())); - fs::write( - &temp, - "\nHost prod\n HostName 10.0.0.1\nHost *.dev !skip\nHost *\n", - ) - .expect("write temp"); - - let candidates = parse_ssh_config_candidates(&temp, "user"); - let _ = fs::remove_file(&temp); - - assert!(candidates - .iter() - .any(|item| item.host == "prod" && !item.pattern)); - assert!(candidates - .iter() - .any(|item| item.host == "*.dev" && item.pattern)); - assert!(!candidates.iter().any(|item| item.host == "*")); - } - - #[test] - fn sanitize_instance_applies_defaults_and_parsed_command() { - let mut instance = sample_instance("ssh-1", "ssh user@example.com"); - instance.connection_timeout_sec = 0; - instance.local_forward.bind_host = "".to_string(); - - let normalized = sanitize_instance(instance).expect("sanitize instance"); - assert_eq!( - normalized.connection_timeout_sec, - DEFAULT_CONNECTION_TIMEOUT_SEC - ); - assert_eq!(normalized.local_forward.bind_host, "127.0.0.1"); - assert_eq!( - normalized.ssh_parsed.expect("parsed").destination, - "user@example.com" - ); - } - - #[test] - fn parse_probe_status_line_extracts_numeric_status() { - assert_eq!( - parse_probe_status_line(Some("INFO_STATUS=401"), "INFO_STATUS="), - Some(401) - ); - assert_eq!( - parse_probe_status_line(Some("INFO_STATUS=abc"), "INFO_STATUS="), - None - ); - assert_eq!( - parse_probe_status_line(Some("WRONG=200"), "INFO_STATUS="), - None - ); - } - - #[test] - fn liveness_status_accepts_success_and_auth_challenges() { - assert!(is_liveness_http_status(200)); - assert!(is_liveness_http_status(204)); - assert!(is_liveness_http_status(401)); - assert!(is_liveness_http_status(403)); - assert!(!is_liveness_http_status(500)); - assert!(!is_liveness_http_status(0)); - } -} diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json deleted file mode 100644 index b60ce1c9..00000000 --- a/packages/desktop/src-tauri/tauri.conf.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "$schema": "../node_modules/@tauri-apps/cli/schema.json", - "productName": "OpenChamber", - "version": "1.12.0", - "identifier": "ai.opencode.openchamber", - "build": { - "beforeDevCommand": "node ./scripts/dev-web-server.mjs", - "beforeBuildCommand": "bun run build:sidecar", - "devUrl": "http://127.0.0.1:3901", - "frontendDist": "../noop-dist" - }, - "app": { - "windows": [ - { - "label": "main", - "create": false, - "title": "OpenChamber", - "width": 1280, - "height": 800, - "resizable": true, - "fullscreen": false, - "decorations": true, - "hiddenTitle": true, - "titleBarStyle": "Overlay", - "trafficLightPosition": { - "x": 17, - "y": 26 - }, - "dragDropEnabled": false, - "visible": false - } - ], - "security": { - "csp": null - }, - "withGlobalTauri": true, - "macOSPrivateApi": true - }, - "bundle": { - "active": true, - "externalBin": [ - "sidecars/openchamber-server" - ], - "resources": [ - "resources/web-dist/**/*" - ], - "icon": [ - "icons/icon.icns", - "icons/icon.png" - ], - "macOS": { - "exceptionDomain": "localhost", - "minimumSystemVersion": "13.0", - "signingIdentity": null, - "entitlements": "./entitlements.plist", - "infoPlist": "Info.plist", - "dmg": { - "appPosition": { - "x": 180, - "y": 170 - }, - "applicationFolderPosition": { - "x": 480, - "y": 170 - }, - "windowSize": { - "width": 660, - "height": 400 - } - } - }, - "createUpdaterArtifacts": true - }, - "plugins": { - "updater": { - "endpoints": [ - "https://github.com/btriapitsyn/openchamber/releases/latest/download/latest.json" - ], - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwRDMyQUQzNjNFRTc1ODIKUldTQ2RlNWoweXJUSUdpVWVWNm84R1pHamYzNVFhYWgyWmlpWFVzem5nUTlHd1dlRlNTV0FFc3IK" - } - } -} diff --git a/packages/desktop/src-tauri/tauri.dev.conf.json b/packages/desktop/src-tauri/tauri.dev.conf.json deleted file mode 100644 index 79324ff9..00000000 --- a/packages/desktop/src-tauri/tauri.dev.conf.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "../node_modules/@tauri-apps/cli/schema.json", - "bundle": { - "icon": [ - "icons/dev-icon.icns", - "icons/dev-icon.png" - ] - } -} diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 00630bb1..8bef3e6f 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -152,7 +152,6 @@ const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local'; const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local'; const ENV_OVERRIDE_HOST_ID = '__env'; const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md'; -const UPDATE_METADATA_URL = 'https://github.com/openchamber/openchamber/releases/latest/download/latest.json'; const GITHUB_BUG_REPORT_URL = 'https://github.com/openchamber/openchamber/issues/new?template=bug_report.yml'; const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/openchamber/openchamber/issues/new?template=feature_request.yml'; const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA'; @@ -1681,7 +1680,6 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } backgroundColor: '#151313', frame: process.platform === 'win32' ? false : undefined, autoHideMenuBar: autoHidesNativeMenuBar, - // Tauri used an overlay title bar with explicit traffic-light placement. // Electron's hiddenInset adds its own extra inset, which leaves the controls // visibly lower than the app header. Use a plain hidden title bar instead. titleBarStyle: usesCustomTitleBar ? 'hidden' : 'default', @@ -1704,7 +1702,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } // sandbox must stay off: the preload uses contextBridge + ipcRenderer // from Electron's Node layer. contextIsolation + nodeIntegration:false // keep the renderer world walled off from Node. Do NOT flip to true — - // the preload would fail to load and __TAURI__ would go undefined. + // the preload would fail to load and the desktop bridge would be unavailable. sandbox: false, }, }; @@ -3028,9 +3026,8 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } case 'desktop_set_vibrancy': { - // Vibrancy (macOS blur) is not supported in the Electron shell — the - // Tauri build used NSVisualEffectView via Tauri plugin, Electron has - // no equivalent for our titleBarStyle:'hidden' setup. Persist the + // Vibrancy (macOS blur) is not supported in the Electron shell for our + // titleBarStyle:'hidden' setup. Persist the // disabled state so settings UI reflects it; args.enabled is ignored. await mutateSettingsRoot((root) => { root.desktopVibrancy = false; @@ -3040,13 +3037,6 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_check_for_updates': { const currentVersion = APP_VERSION; - let payload = null; - try { - const response = await fetch(UPDATE_METADATA_URL, { signal: AbortSignal.timeout(10_000) }); - payload = await response.json(); - } catch { - } - let updateResult = null; try { updateResult = await autoUpdater.checkForUpdates(); @@ -3056,14 +3046,12 @@ const handleInvoke = async (browserWindow, command, args = {}) => { const updateInfo = updateResult?.updateInfo; const nextVersion = (typeof updateInfo?.version === 'string' && updateInfo.version) || - (typeof payload?.version === 'string' && payload.version) || currentVersion; const available = compareSemver(nextVersion, currentVersion) > 0; const body = - (typeof payload?.notes === 'string' && payload.notes.trim() ? payload.notes : null) || (typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) || await parseRelevantChangelogNotes(currentVersion, nextVersion); - state.pendingUpdate = available ? { version: nextVersion, metadata: payload, electronUpdate: updateResult } : null; + state.pendingUpdate = available ? { version: nextVersion, electronUpdate: updateResult } : null; return { available, currentVersion, @@ -3071,7 +3059,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { body: body || null, date: (typeof updateInfo?.releaseDate === 'string' && updateInfo.releaseDate) || - (typeof payload?.pub_date === 'string' ? payload.pub_date : null), + null, }; } diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index e4e32d48..6a2fb9b3 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -24,8 +24,8 @@ const macosMajor = Number.parseInt(macosMajorRaw, 10); // Remote UIs still need it so isDesktopShell() returns true and the // window renders with desktop affordances (DesktopHostSwitcher, // title bar offsets, etc.). Expose unconditionally. -// - __TAURI__ is the IPC channel to the main process. The compatibility -// shim is exposed broadly, but privileged commands are gated in main.mjs. +// - __OPENCHAMBER_DESKTOP__ is the IPC channel to the main process. It is +// exposed broadly, but privileged commands are gated in main.mjs. // Local-only globals below stay limited to packaged UI / exact localOrigin. // Everything driven by localOrigin (home dir, macOS hints) also stays // local-only since it leaks info about the Electron host machine. @@ -136,21 +136,13 @@ ipcRenderer.on('openchamber:emit', (_evt, payload) => { dispatchNativeEvent(event, payload.detail); }); -// __TAURI__ is exposed on all pages; the main-process gate in +// The desktop bridge is exposed on all pages; the main-process gate in // ipcMain.handle('openchamber:invoke') decides per-command what is safe // for non-local callers (window/host-switcher ops yes, file/shell ops // no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs. -contextBridge.exposeInMainWorld('__TAURI__', { - core: { - invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}), - }, - dialog: { - open: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}), - }, - shell: { - open: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }), - }, - event: { - listen: async (event, handler) => addListener(event, handler), - }, +contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', { + invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}), + openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}), + openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }), + listen: async (event, handler) => addListener(event, handler), }); diff --git a/packages/ui/package.json b/packages/ui/package.json index aa4b8983..30040f8f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -83,7 +83,6 @@ "devDependencies": { "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", - "@tauri-apps/api": "^2.10.1", "@types/node": "^24.3.1", "@types/prismjs": "^1.26.6", "@types/qrcode": "^1.5.5", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index a8981772..fc096ec5 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -17,7 +17,7 @@ import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useConfigStore } from '@/stores/useConfigStore'; import { hasModifier } from '@/lib/utils'; -import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, restartDesktopApp } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp } from '@/lib/desktop'; import { getInjectedBootOutcome, getBootInjectionStatus, @@ -758,7 +758,7 @@ function App({ apis }: AppProps) { const handleDesktopBootDismiss = React.useCallback(async () => { if (shouldRestartDesktopBootFlow({ - isTauriShell: isTauriShell(), + isDesktopShell: isDesktopShell(), isDesktopLocalOriginActive: isDesktopLocalOriginActive(), })) { await restartDesktopApp(); diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 43037233..dfcc5e9e 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui'; -import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; +import { invokeDesktop, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence'; import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence'; import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher'; @@ -129,11 +129,7 @@ const issueDesktopClientTokenViaShell = async (password: string, trustDevice: bo if (!isDesktopShell() || typeof window === 'undefined') { return ''; } - const invoke = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record) => Promise } } }).__TAURI__?.core?.invoke; - if (typeof invoke !== 'function') { - return ''; - } - const response = await invoke('desktop_remote_password_login', { + const response = await invokeDesktop('desktop_remote_password_login', { url: getRuntimeApiBaseUrl(), password, trustDevice, diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 059c5d73..3d9b7a51 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -36,8 +36,7 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; // useMessageStore removed — messages now come from sync system -import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop'; -import { runtimeFetch } from '@/lib/runtime-fetch'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { StopIcon } from '@/components/icons/StopIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -968,7 +967,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const suppressNextFileDropTextInsertTimeoutRef = React.useRef | null>(null); const pendingDroppedAbsolutePathsRef = React.useRef([]); const canAcceptDropRef = React.useRef(false); - const nativeDragInsideDropZoneRef = React.useRef(false); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const skillRef = React.useRef(null); @@ -3407,121 +3405,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } }; - // Tauri desktop: handle native file drops via onDragDropEvent - React.useEffect(() => { - if (!isTauriShell()) return; - let cancelled = false; - let unlisten: (() => void) | null = null; - - void (async () => { - try { - const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow'); - const webviewWindow = getCurrentWebviewWindow(); - const removeListener = await webviewWindow.onDragDropEvent(async (event) => { - if (!canAcceptDropRef.current) return; - - const payload = (event as { payload?: unknown }).payload; - if (!payload || typeof payload !== 'object') return; - - const typed = payload as { type?: string; paths?: string[]; position?: { x?: number; y?: number } }; - const type = typed.type; - const x = typed.position?.x; - const y = typed.position?.y; - - // Check if drop is inside the chat input area - const zone = dropZoneRef.current; - let inZone: boolean | null = null; - if (zone && typeof x === 'number' && typeof y === 'number') { - const rect = zone.getBoundingClientRect(); - inZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; - // Handle retina displays where Tauri might report physical pixels - if (!inZone && window.devicePixelRatio > 1) { - const sx = x / window.devicePixelRatio; - const sy = y / window.devicePixelRatio; - inZone = sx >= rect.left && sx <= rect.right && sy >= rect.top && sy <= rect.bottom; - } - } - - if (type === 'enter' || type === 'over') { - if (inZone !== null) { - nativeDragInsideDropZoneRef.current = inZone; - } - setIsDragging(nativeDragInsideDropZoneRef.current); - return; - } - if (type === 'leave') { - nativeDragInsideDropZoneRef.current = false; - setIsDragging(false); - return; - } - if (type === 'drop') { - const shouldHandleDrop = inZone ?? nativeDragInsideDropZoneRef.current; - nativeDragInsideDropZoneRef.current = false; - setIsDragging(false); - if (!shouldHandleDrop) return; - - const paths = Array.isArray(typed.paths) - ? typed.paths.filter((p): p is string => typeof p === 'string') - : []; - if (paths.length === 0) return; - - for (const path of paths) { - try { - const normalizedPath = normalizeDroppedPath(path); - const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath; - let file: File; - - // In Tauri shell, dropped paths are local machine paths. - // Read bytes via native command to avoid workspace-bound /api/fs/raw restrictions. - if (isTauriShell()) { - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath }); - const byteCharacters = atob(result.base64); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { type: result.mime || 'application/octet-stream' }); - file = new File([blob], fileName, { type: result.mime || 'application/octet-stream' }); - } else { - const response = await runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } }); - if (!response.ok) { - throw new Error(`Failed to read dropped file (${response.status})`); - } - const blob = await response.blob(); - file = new File([blob], fileName, { type: blob.type || 'application/octet-stream' }); - } - - await addAttachedFile(file); - } catch (error) { - console.error('Failed to attach dropped file:', path, error); - toast.error(t('chat.chatInput.toast.attachNamedFailed', { - name: path.split(/[\\/]/).pop() || t('chat.chatInput.fileFallback'), - })); - } - } - } - }); - - if (cancelled) { - removeListener(); - return; - } - unlisten = removeListener; - } catch (error) { - if (!cancelled) { - console.warn('Failed to register Tauri drag-drop listener:', error); - } - } - })(); - - return () => { - cancelled = true; - if (unlisten) unlisten(); - }; - }, [addAttachedFile, normalizeDroppedPath, t]); - const fileInputRef = React.useRef(null); const attachFiles = React.useCallback(async (files: FileList | File[]) => { diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 72a73685..8003c1b0 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui'; -import { isElectronShell, isTauriShell, isDesktopShell } from '@/lib/desktop'; +import { isElectronShell, isDesktopShell } from '@/lib/desktop'; import { Icon } from "@/components/icon/Icon"; import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; @@ -354,7 +354,7 @@ export function DesktopHostSwitcherDialog({ }, [allHosts, defaultHostId, t]); const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => { - if (!isTauriShell()) return; + if (!isDesktopShell()) return; setIsSaving(true); setError(''); try { @@ -376,7 +376,7 @@ export function DesktopHostSwitcherDialog({ }, [onOpenChange, setSettingsDialogOpen, setSettingsPage]); const refresh = React.useCallback(async () => { - if (!isTauriShell()) return; + if (!isDesktopShell()) return; setIsLoading(true); setError(''); try { @@ -408,7 +408,7 @@ export function DesktopHostSwitcherDialog({ }, [t]); const probeAll = React.useCallback(async (hosts: DesktopHost[]) => { - if (!isTauriShell()) return; + if (!isDesktopShell()) return; setIsProbing(true); const nextProbingHostIds: Record = {}; for (const host of hosts) { @@ -458,7 +458,7 @@ export function DesktopHostSwitcherDialog({ }, [open, allHosts, probeAll]); React.useEffect(() => { - if (!open || !isTauriShell()) { + if (!open || !isDesktopShell()) { return; } let cancelled = false; @@ -512,7 +512,7 @@ export function DesktopHostSwitcherDialog({ const isSshHost = Boolean(sshHostIds[host.id]); - if (host.id !== LOCAL_HOST_ID && isSshHost && isTauriShell()) { + if (host.id !== LOCAL_HOST_ID && isSshHost && isDesktopShell()) { let existingStatus = sshStatusesById[host.id]; const latestStatus = await desktopSshStatus(host.id) .then((items) => items.find((item) => item.id === host.id) || null) @@ -596,7 +596,7 @@ export function DesktopHostSwitcherDialog({ } } - if (host.id !== LOCAL_HOST_ID && isTauriShell()) { + if (host.id !== LOCAL_HOST_ID && isDesktopShell()) { setSwitchingHostId(host.id); const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); setStatusById((prev) => ({ @@ -705,7 +705,7 @@ export function DesktopHostSwitcherDialog({ error: null, }); - if (!hostId || hostId === LOCAL_HOST_ID || !isTauriShell()) { + if (!hostId || hostId === LOCAL_HOST_ID || !isDesktopShell()) { return; } @@ -721,7 +721,7 @@ export function DesktopHostSwitcherDialog({ }, [allHosts, handleSwitch, sshSwitchModal.hostId]); const connectSshHostInPlace = React.useCallback(async (host: DesktopHost) => { - if (!isTauriShell()) return; + if (!isDesktopShell()) return; setSwitchingHostId(host.id); try { await desktopSshConnect(host.id); @@ -750,7 +750,7 @@ export function DesktopHostSwitcherDialog({ return null; } - const tauriAvailable = isTauriShell(); + const desktopAvailable = isDesktopShell(); const content = ( <> @@ -772,7 +772,7 @@ export function DesktopHostSwitcherDialog({ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary' )} onClick={() => void probeAll(allHosts)} - disabled={!tauriAvailable || isLoading || isProbing} + disabled={!desktopAvailable || isLoading || isProbing} aria-label={t('desktopHostSwitcher.actions.refreshInstancesAria')} > @@ -805,7 +805,7 @@ export function DesktopHostSwitcherDialog({ size="sm" variant="ghost" onClick={() => void probeAll(allHosts)} - disabled={!tauriAvailable || isLoading || isProbing} + disabled={!desktopAvailable || isLoading || isProbing} > {t('desktopHostSwitcher.actions.refresh')} @@ -814,7 +814,7 @@ export function DesktopHostSwitcherDialog({ )} - {!tauriAvailable && ( + {!desktopAvailable && (
{t('desktopHostSwitcher.state.limitedOnPage')} @@ -974,7 +974,7 @@ export function DesktopHostSwitcherDialog({
- {tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && ( + {desktopAvailable && editingId && editingId !== LOCAL_HOST_ID && (
{t('desktopHostSwitcher.edit.title')}
@@ -1202,7 +1202,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost }, [connectDefaultSshInstance, startupSshModal.hostId, startupSshModal.hostLabel]); React.useEffect(() => { - if (!isTauriShell()) return; + if (!isDesktopShell()) return; let cancelled = false; const run = async () => { diff --git a/packages/ui/src/components/desktop/OpenInAppButton.tsx b/packages/ui/src/components/desktop/OpenInAppButton.tsx index dc7ae966..24bccc12 100644 --- a/packages/ui/src/components/desktop/OpenInAppButton.tsx +++ b/packages/ui/src/components/desktop/OpenInAppButton.tsx @@ -10,7 +10,7 @@ import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; -import { isDesktopLocalOriginActive, isTauriShell, openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isDesktopShell, openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop'; import { DEFAULT_OPEN_IN_APP_ID, OPEN_IN_APPS } from '@/lib/openInApps'; import { useOpenInAppsStore, type OpenInAppOption } from '@/stores/useOpenInAppsStore'; import { useI18n } from '@/lib/i18n'; @@ -87,7 +87,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) initialize(); }, [initialize]); - const isDesktopLocal = isTauriShell() && isDesktopLocalOriginActive(); + const isDesktopLocal = isDesktopShell() && isDesktopLocalOriginActive(); const selectedApp = React.useMemo(() => { const known = availableApps.find((app) => app.id === selectedAppId) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 7903aa89..dc02f2cc 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1660,15 +1660,12 @@ export const Header: React.FC = ({ } let disposed = false; - let unlistenResize: (() => void) | null = null; const syncFullscreenState = async () => { try { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const currentWindow = getCurrentWindow(); - const fullscreen = await currentWindow.isFullscreen(); + const fullscreen = await invokeDesktop('desktop_is_window_fullscreen'); if (!disposed) { - setIsDesktopWindowFullscreen(fullscreen); + setIsDesktopWindowFullscreen(fullscreen === true); } } catch { if (!disposed) { @@ -1677,26 +1674,16 @@ export const Header: React.FC = ({ } }; - const attach = async () => { - try { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const currentWindow = getCurrentWindow(); - unlistenResize = await currentWindow.onResized(() => { - void syncFullscreenState(); - }); - } catch { - // Ignore listener setup failures; fallback state remains false. - } + const onResize = () => { + void syncFullscreenState(); }; void syncFullscreenState(); - void attach(); + window.addEventListener('openchamber:window-resized', onResize); return () => { disposed = true; - if (unlistenResize) { - unlistenResize(); - } + window.removeEventListener('openchamber:window-resized', onResize); }; }, [isDesktopApp, isMacPlatform]); diff --git a/packages/ui/src/components/onboarding/ChooserScreen.tsx b/packages/ui/src/components/onboarding/ChooserScreen.tsx index 88e19162..754aefc3 100644 --- a/packages/ui/src/components/onboarding/ChooserScreen.tsx +++ b/packages/ui/src/components/onboarding/ChooserScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { isDesktopShell, isTauriShell, startDesktopWindowDrag } from '@/lib/desktop'; +import { isDesktopShell, requestFileAccess, startDesktopWindowDrag } from '@/lib/desktop'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Icon } from "@/components/icon/Icon"; @@ -116,7 +116,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { }, []); const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => { - if (!isTauriShell()) return; + if (!isDesktopApp) return; const config = await desktopHostsGet(); await desktopHostsSet({ @@ -124,14 +124,14 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { ...(choice === 'local' ? { defaultHostId: 'local' } : {}), initialHostChoiceCompleted: true, }); - }, []); + }, [isDesktopApp]); const announceAvailable = React.useCallback(async () => { - if (isTauriShell()) { + if (isDesktopApp) { await persistFirstChoice('local'); } onCliAvailable?.(); - }, [onCliAvailable, persistFirstChoice]); + }, [isDesktopApp, onCliAvailable, persistFirstChoice]); // Background polling: while the local tab is visible, periodically check // whether the OpenCode CLI is reachable. As soon as it is, transition @@ -179,30 +179,23 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { const handleBrowse = React.useCallback(async () => { if (typeof window === 'undefined') return; - if (!isDesktopApp || !isTauriShell()) return; - - const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record) => Promise } } }).__TAURI__; - if (!tauri?.dialog?.open) return; + if (!isDesktopApp) return; try { - const selected = await tauri.dialog.open({ - title: t('onboarding.localSetup.dialog.selectOpencodeBinary'), - multiple: false, - directory: false, - }); - if (typeof selected === 'string' && selected.trim().length > 0) { - setOpencodeBinary(selected.trim()); + const selected = await requestFileAccess(); + if (selected.success && selected.path && selected.path.trim().length > 0) { + setOpencodeBinary(selected.path.trim()); } } catch { // ignore } - }, [isDesktopApp, t]); + }, [isDesktopApp]); const handleApplyPath = React.useCallback(async () => { setIsApplyingPath(true); try { await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() }); - if (isTauriShell()) { + if (isDesktopApp) { await persistFirstChoice('local'); await restartDesktopApp(); return; @@ -211,7 +204,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { } finally { setTimeout(() => setIsApplyingPath(false), 1000); } - }, [opencodeBinary, persistFirstChoice]); + }, [isDesktopApp, opencodeBinary, persistFirstChoice]); const handleCopy = React.useCallback(async () => { const result = await copyTextToClipboard(INSTALL_COMMAND); @@ -231,7 +224,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { ? '/home/you/.bun/bin/opencode' : '/Users/you/.bun/bin/opencode'; - const showLocal = !isDesktopApp || !isTauriShell() || activeTab === 'local'; + const showLocal = !isDesktopApp || activeTab === 'local'; return (
- {isDesktopApp && isTauriShell() && ( + {isDesktopApp && (
diff --git a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx index b04c4246..2d5f7434 100644 --- a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx +++ b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { isDesktopShell, isTauriShell } from '@/lib/desktop'; +import { isDesktopShell, requestFileAccess, startDesktopWindowDrag } from '@/lib/desktop'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Icon } from "@/components/icon/Icon"; @@ -122,14 +122,8 @@ export function LocalSetupScreen({ return; } if (e.button !== 0) return; - if (isDesktopApp && isTauriShell()) { - try { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const window = getCurrentWindow(); - await window.startDragging(); - } catch (error) { - console.error('Failed to start window dragging:', error); - } + if (isDesktopApp) { + await startDesktopWindowDrag(); } }, [isDesktopApp]); @@ -148,37 +142,28 @@ export function LocalSetupScreen({ if (typeof window === 'undefined') { return; } - if (!isDesktopApp || !isTauriShell()) { - return; - } - - const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record) => Promise } } }).__TAURI__; - if (!tauri?.dialog?.open) { + if (!isDesktopApp) { return; } try { - const selected = await tauri.dialog.open({ - title: t('onboarding.localSetup.dialog.selectOpencodeBinary'), - multiple: false, - directory: false, - }); - if (typeof selected === 'string' && selected.trim().length > 0) { - setOpencodeBinary(selected.trim()); + const selected = await requestFileAccess(); + if (selected.success && selected.path && selected.path.trim().length > 0) { + setOpencodeBinary(selected.path.trim()); } } catch { // ignore } - }, [isDesktopApp, t]); + }, [isDesktopApp]); const handleApplyPath = React.useCallback(async () => { setIsRetrying(true); try { await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() }); - // In desktop boot flow, always restart the entire Tauri app so Rust - // can re-evaluate the boot outcome with the updated binary path. - if (isTauriShell()) { + // In desktop boot flow, restart the app so the native host can + // re-evaluate the boot outcome with the updated binary path. + if (isDesktopApp) { await restartDesktopApp(); return; } @@ -187,7 +172,7 @@ export function LocalSetupScreen({ } finally { setTimeout(() => setIsRetrying(false), 1000); } - }, [opencodeBinary]); + }, [isDesktopApp, opencodeBinary]); const handleCopy = React.useCallback(async () => { const result = await copyTextToClipboard(INSTALL_COMMAND); @@ -321,7 +306,7 @@ export function LocalSetupScreen({ type="button" variant="secondary" onClick={handleBrowse} - disabled={isRetrying || !isDesktopApp || !isTauriShell()} + disabled={isRetrying || !isDesktopApp} > {t('onboarding.localSetup.actions.browse')} diff --git a/packages/ui/src/components/onboarding/RecoveryScreen.tsx b/packages/ui/src/components/onboarding/RecoveryScreen.tsx index 83fa4552..09a7af58 100644 --- a/packages/ui/src/components/onboarding/RecoveryScreen.tsx +++ b/packages/ui/src/components/onboarding/RecoveryScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { isTauriShell, restartDesktopApp } from '@/lib/desktop'; +import { isDesktopShell, restartDesktopApp } from '@/lib/desktop'; import { DesktopConnectionRecovery, type RecoveryVariant } from './DesktopConnectionRecovery'; import { RemoteConnectionForm } from './RemoteConnectionForm'; import { resolveRecoveryNextStep } from './desktopRecoveryRouting'; @@ -43,7 +43,7 @@ export function RecoveryScreen({ }: RecoveryScreenProps) { // Persist the user's first choice (local or remote) const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => { - if (!isTauriShell()) return; + if (!isDesktopShell()) return; const config = await desktopHostsGet(); await desktopHostsSet({ @@ -56,9 +56,9 @@ export function RecoveryScreen({ }, []); const handleRecoveryRetry = React.useCallback(async () => { - // In desktop boot flow, always restart the entire Tauri app so Rust - // can re-evaluate the boot outcome. - if (isTauriShell()) { + // In desktop boot flow, restart the app so the native host can + // re-evaluate the boot outcome. + if (isDesktopShell()) { await restartDesktopApp(); return; } @@ -77,7 +77,7 @@ export function RecoveryScreen({ // switch-default-to-local → persist local choice and restart await persistFirstChoice('local'); - if (isTauriShell()) { + if (isDesktopShell()) { await restartDesktopApp(); return; } @@ -105,7 +105,7 @@ export function RecoveryScreen({ isRecoveryMode={true} onSwitchToLocal={onSwitchToLocalFromRemote || (() => { persistFirstChoice('local').then(() => { - if (isTauriShell()) { + if (isDesktopShell()) { restartDesktopApp(); } else { onEnterLocalSetup?.(); diff --git a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx index a17a91e9..161a58c4 100644 --- a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx +++ b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx @@ -8,7 +8,7 @@ import { } from '@/lib/desktopHosts'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { isTauriShell } from '@/lib/desktop'; +import { isDesktopShell, restartDesktopApp } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; type ConnectionState = 'idle' | 'testing' | 'success' | 'error'; @@ -153,9 +153,8 @@ export function RemoteConnectionForm({ return; } - if (isTauriShell()) { - const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record) => Promise } } }).__TAURI__; - await tauri?.core?.invoke?.('desktop_restart'); + if (isDesktopShell()) { + await restartDesktopApp(); } } catch (err) { setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.failedToSaveConnection')); diff --git a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx index 7feaa686..6fa6a9d8 100644 --- a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx @@ -4,7 +4,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from "@/components/icon/Icon"; -import { isDesktopShell, isTauriShell } from '@/lib/desktop'; +import { isDesktopShell, requestFileAccess } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -54,28 +54,19 @@ export const OpenCodeCliSettings: React.FC = () => { return; } - if (!isDesktopShell() || !isTauriShell()) { - return; - } - - const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record) => Promise } } }).__TAURI__; - if (!tauri?.dialog?.open) { + if (!isDesktopShell()) { return; } try { - const selected = await tauri.dialog.open({ - title: t('settings.openchamber.opencodeCli.dialog.selectBinaryTitle'), - multiple: false, - directory: false, - }); - if (typeof selected === 'string' && selected.trim().length > 0) { - setValue(selected.trim()); + const selected = await requestFileAccess(); + if (selected.success && selected.path && selected.path.trim().length > 0) { + setValue(selected.path.trim()); } } catch { // ignore } - }, [t]); + }, []); const handleSaveAndReload = React.useCallback(async () => { setIsSaving(true); @@ -130,7 +121,7 @@ export const OpenCodeCliSettings: React.FC = () => { variant="outline" size="xs" onClick={handleBrowse} - disabled={isLoading || isSaving || !isDesktopShell() || !isTauriShell()} + disabled={isLoading || isSaving || !isDesktopShell()} className="h-7 w-7 p-0" aria-label={t('settings.openchamber.opencodeCli.actions.browseAria')} title={t('settings.openchamber.opencodeCli.actions.browse')} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index d951d3d3..baef3896 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -42,7 +42,6 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; import type { Extension } from '@codemirror/state'; -import { convertFileSrc } from '@tauri-apps/api/core'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useUIStore } from '@/stores/useUIStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; @@ -2629,7 +2628,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const srcPromise = files.readFileBinary ? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl) - : Promise.resolve(convertFileSrc(selectedFile.path, 'asset')); + : Promise.resolve(getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { + path: selectedFile.path, + allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, + })); await srcPromise .then((src) => { diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index 4bfa4f60..9db7bfa4 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -7,7 +7,7 @@ import React, { import { flushSync } from 'react-dom'; import type { Theme, ThemeMode } from '@/types/theme'; import type { DesktopSettings } from '@/lib/desktop'; -import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isDesktopShell as detectDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { setDesktopWindowTheme } from '@/lib/desktopNative'; import { CSSVariableGenerator } from '@/lib/theme/cssGenerator'; import { updateDesktopSettings } from '@/lib/persistence'; @@ -220,7 +220,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro }); const isVSCode = useMemo(() => isVSCodeRuntime(), []); const isLocalDesktopOrigin = useMemo(() => isDesktopLocalOriginActive(), []); - const isDesktopShell = useMemo(() => isTauriShell(), []); + const isDesktopShell = useMemo(() => detectDesktopShell(), []); const availableThemes = useMemo(() => { const merged: Theme[] = []; diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index e585e31a..d9bea5e7 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -56,17 +56,13 @@ const copyCurrentSelectionFallback = async (): Promise => { const MENU_ACTION_EVENT = 'openchamber:menu-action'; const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates'; -type TauriEventApi = { +type DesktopBridgeGlobal = { listen?: ( event: string, handler: (evt: { payload?: unknown }) => void ) => Promise<() => void>; }; -type TauriGlobal = { - event?: TauriEventApi; -}; - type MenuAction = | 'about' | 'settings' @@ -344,8 +340,8 @@ export const useMenuActions = ( React.useEffect(() => { if (typeof window === 'undefined') return; - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - const listen = tauri?.event?.listen; + const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__; + const listen = desktop?.listen; if (typeof listen !== 'function') return; let unlistenMenu: null | (() => void | Promise) = null; diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts index e49f905e..c5896075 100644 --- a/packages/ui/src/hooks/useRouter.ts +++ b/packages/ui/src/hooks/useRouter.ts @@ -27,7 +27,7 @@ function isVSCodeContext(): boolean { * * Works in: * - Web: Full bidirectional sync - * - Desktop (Tauri): Full bidirectional sync + * - Desktop: Full bidirectional sync * - VS Code: State-only (no URL updates, reads initial params) */ export function useRouter(): void { diff --git a/packages/ui/src/hooks/useWindowTitle.ts b/packages/ui/src/hooks/useWindowTitle.ts index e05f0d38..87e64019 100644 --- a/packages/ui/src/hooks/useWindowTitle.ts +++ b/packages/ui/src/hooks/useWindowTitle.ts @@ -1,6 +1,6 @@ import React from 'react'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop'; import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts'; import { setDesktopWindowTitle } from '@/lib/desktopNative'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; @@ -110,7 +110,7 @@ export const useWindowTitle = () => { document.title = title; } - if (!isTauriShell()) { + if (!isDesktopShell()) { return; } diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index 7903e854..bafb8272 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -221,7 +221,7 @@ export const debugUtils = { })(); const runtimeApis = getRegisteredRuntimeAPIs(); - const isTauriShell = typeof window !== 'undefined' && Boolean((window as any).__TAURI__); + const isDesktopRuntime = typeof window !== 'undefined' && Boolean((window as { __OPENCHAMBER_ELECTRON__?: unknown }).__OPENCHAMBER_ELECTRON__); const safeJson = async (resp: Response) => { try { @@ -327,7 +327,7 @@ export const debugUtils = { const report = { runtime: { platform: runtimeApis?.runtime?.platform ?? null, - isDesktop: isTauriShell, + isDesktop: isDesktopRuntime, isVSCode: Boolean(runtimeApis?.runtime?.isVSCode), hasRuntimeApis: Boolean(runtimeApis), desktopServerOrigin: null, diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index bf52b185..a3143f08 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -185,19 +185,14 @@ export type DesktopSettings = { draftStarters?: DraftStarterRef[]; }; -type TauriGlobal = { - core?: { - invoke?: (cmd: string, args?: Record) => Promise; - }; - dialog?: { - open?: (options: Record) => Promise; - }; - event?: { - listen?: ( - event: string, - handler: (evt: { payload?: unknown }) => void, - ) => Promise<() => void>; - }; +type DesktopBridgeGlobal = { + invoke?: (cmd: string, args?: Record) => Promise; + openDialog?: (options: Record) => Promise; + openExternal?: (url: string) => Promise; + listen?: ( + event: string, + handler: (evt: { payload?: unknown }) => void, + ) => Promise<() => void>; }; type ElectronRuntimeGlobal = { @@ -209,27 +204,23 @@ const getElectronRuntime = (): ElectronRuntimeGlobal | null => { return (window as unknown as { __OPENCHAMBER_ELECTRON__?: ElectronRuntimeGlobal }).__OPENCHAMBER_ELECTRON__ ?? null; }; -export const isTauriShell = (): boolean => { - if (typeof window === 'undefined') return false; - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - return typeof tauri?.core?.invoke === 'function'; +const getDesktopBridge = (): DesktopBridgeGlobal | null => { + if (typeof window === 'undefined') return null; + return (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__ ?? null; }; export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron'; export const hasDesktopInvoke = (): boolean => { - if (typeof window === 'undefined') return false; - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - return typeof tauri?.core?.invoke === 'function'; + return typeof getDesktopBridge()?.invoke === 'function'; }; export const canUseElectronDesktopIPC = (): boolean => isElectronShell() && hasDesktopInvoke(); export const invokeDesktop = async (command: string, args?: Record): Promise => { - if (typeof window === 'undefined') return null; - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - if (typeof tauri?.core?.invoke !== 'function') return null; - return tauri.core.invoke(command, args ?? {}) as Promise; + const bridge = getDesktopBridge(); + if (typeof bridge?.invoke !== 'function') return null; + return bridge.invoke(command, args ?? {}) as Promise; }; type LaunchAtLoginStatus = { @@ -367,18 +358,16 @@ export const isDesktopLocalOriginActive = (): boolean => { export const isDesktopShell = (): boolean => { if (typeof window === 'undefined') return false; - return isTauriShell() || isElectronShell(); + return isElectronShell(); }; export const startDesktopWindowDrag = async (): Promise => { - if (!isDesktopShell() || !isTauriShell()) { + if (!isDesktopShell()) { return false; } try { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const appWindow = getCurrentWindow(); - await appWindow.startDragging(); + await invokeDesktop('desktop_start_window_drag'); return true; } catch { return false; @@ -418,10 +407,9 @@ export const requestDirectoryAccess = async ( directoryPath: string ): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => { // Desktop shell on local instance: use native folder picker. - if (isTauriShell() && isDesktopLocalOriginActive()) { + if (hasDesktopInvoke() && isDesktopLocalOriginActive()) { try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - const selected = await tauri?.dialog?.open?.({ + const selected = await getDesktopBridge()?.openDialog?.({ directory: true, multiple: false, title: 'Select Working Directory', @@ -431,7 +419,7 @@ export const requestDirectoryAccess = async ( } return { success: true, path: selected }; } catch (error) { - console.warn('Failed to request directory access (tauri)', error); + console.warn('Failed to request directory access', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } @@ -442,10 +430,9 @@ export const requestDirectoryAccess = async ( export const requestFileAccess = async ( options?: { filters?: Array<{ name: string; extensions: string[] }>; defaultPath?: string } ): Promise<{ success: boolean; path?: string; error?: string }> => { - if (isTauriShell() && isDesktopLocalOriginActive()) { + if (hasDesktopInvoke() && isDesktopLocalOriginActive()) { try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - const selected = await tauri?.dialog?.open?.({ + const selected = await getDesktopBridge()?.openDialog?.({ directory: false, multiple: false, title: 'Select File', @@ -457,7 +444,7 @@ export const requestFileAccess = async ( } return { success: true, path: selected }; } catch (error) { - console.warn('Failed to request file access (tauri)', error); + console.warn('Failed to request file access', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } } @@ -482,10 +469,9 @@ export const stopAccessingDirectory = async ( export const sendAssistantCompletionNotification = async ( payload?: AssistantNotificationPayload ): Promise => { - if (isTauriShell()) { + if (hasDesktopInvoke()) { try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - await tauri?.core?.invoke?.('desktop_notify', { + await invokeDesktop('desktop_notify', { payload: { title: payload?.title, body: payload?.body, @@ -494,7 +480,7 @@ export const sendAssistantCompletionNotification = async ( }); return true; } catch (error) { - console.warn('Failed to send assistant completion notification (tauri)', error); + console.warn('Failed to send assistant completion notification', error); return false; } } @@ -503,16 +489,15 @@ export const sendAssistantCompletionNotification = async ( }; export const checkForDesktopUpdates = async (): Promise => { - if (!isTauriShell()) { + if (!hasDesktopInvoke()) { return null; } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - const info = await tauri?.core?.invoke?.('desktop_check_for_updates'); + const info = await invokeDesktop('desktop_check_for_updates'); return info as UpdateInfo; } catch (error) { - console.warn('Failed to check for updates (tauri)', error); + console.warn('Failed to check for updates', error); return null; } }; @@ -520,18 +505,18 @@ export const checkForDesktopUpdates = async (): Promise => { export const downloadDesktopUpdate = async ( onProgress?: (progress: UpdateProgress) => void ): Promise => { - if (!isTauriShell()) { + if (!hasDesktopInvoke()) { return false; } - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + const bridge = getDesktopBridge(); let unlisten: null | (() => void | Promise) = null; let downloaded = 0; let total: number | undefined; try { - if (typeof onProgress === 'function' && tauri?.event?.listen) { - unlisten = await tauri.event.listen('openchamber:update-progress', (evt) => { + if (typeof onProgress === 'function' && bridge?.listen) { + unlisten = await bridge.listen('openchamber:update-progress', (evt) => { const payload = evt?.payload; if (!payload || typeof payload !== 'object') return; const data = payload as { event?: unknown; data?: unknown }; @@ -560,10 +545,10 @@ export const downloadDesktopUpdate = async ( }); } - await tauri?.core?.invoke?.('desktop_download_and_install_update'); + await invokeDesktop('desktop_download_and_install_update'); return true; } catch (error) { - console.warn('Failed to download update (tauri)', error); + console.warn('Failed to download update', error); return false; } finally { if (unlisten) { @@ -580,7 +565,7 @@ export const downloadDesktopUpdate = async ( }; export const restartToApplyUpdate = async (): Promise => { - if (!isTauriShell()) { + if (!hasDesktopInvoke()) { return false; } @@ -588,37 +573,35 @@ export const restartToApplyUpdate = async (): Promise => { }; export const restartDesktopApp = async (): Promise => { - if (!isTauriShell()) { + if (!hasDesktopInvoke()) { return false; } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - await tauri?.core?.invoke?.('desktop_restart'); + await invokeDesktop('desktop_restart'); return true; } catch (error) { - console.warn('Failed to restart desktop app (tauri)', error); + console.warn('Failed to restart desktop app', error); return false; } }; export const getDesktopLanAddress = async (): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return null; } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - const result = await tauri?.core?.invoke?.('desktop_get_lan_address'); + const result = await invokeDesktop('desktop_get_lan_address'); return typeof result === 'string' && result.trim().length > 0 ? result.trim() : null; } catch (error) { - console.warn('Failed to get desktop LAN address (tauri)', error); + console.warn('Failed to get desktop LAN address', error); return null; } }; export const openDesktopPath = async (path: string, app?: string | null): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return false; } @@ -628,20 +611,19 @@ export const openDesktopPath = async (path: string, app?: string | null): Promis } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - await tauri?.core?.invoke?.('desktop_open_path', { + await invokeDesktop('desktop_open_path', { path: trimmed, app: typeof app === 'string' && app.trim().length > 0 ? app.trim() : undefined, }); return true; } catch (error) { - console.warn('Failed to open path (tauri)', error); + console.warn('Failed to open path', error); return false; } }; export const revealDesktopPath = async (path: string): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return false; } @@ -651,8 +633,7 @@ export const revealDesktopPath = async (path: string): Promise => { } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - await tauri?.core?.invoke?.('desktop_reveal_path', { + await invokeDesktop('desktop_reveal_path', { path: trimmed, }); return true; @@ -665,7 +646,7 @@ export const saveDesktopMarkdownFile = async ( defaultFileName: string, content: string, ): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return null; } @@ -675,14 +656,13 @@ export const saveDesktopMarkdownFile = async ( } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - const result = await tauri?.core?.invoke?.('desktop_save_markdown_file', { + const result = await invokeDesktop('desktop_save_markdown_file', { defaultFileName: trimmedFileName, content, }); return typeof result === 'string' && result.trim().length > 0 ? result : null; } catch (error) { - console.warn('Failed to save markdown file (tauri)', error); + console.warn('Failed to save markdown file', error); return null; } }; @@ -692,7 +672,7 @@ export const openDesktopProjectInApp = async ( appId: string, appName: string, ): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return false; } @@ -705,8 +685,7 @@ export const openDesktopProjectInApp = async ( } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - await tauri?.core?.invoke?.('desktop_open_in_app', { + await invokeDesktop('desktop_open_in_app', { projectPath: trimmedProjectPath, appId: trimmedAppId, appName: trimmedAppName, @@ -723,7 +702,7 @@ export const openDesktopFileInApp = async ( appId: string, appName: string, ): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return false; } @@ -736,8 +715,7 @@ export const openDesktopFileInApp = async ( } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - await tauri?.core?.invoke?.('desktop_open_file_in_app', { + await invokeDesktop('desktop_open_file_in_app', { filePath: trimmedFilePath, appId: trimmedAppId, appName: trimmedAppName, @@ -750,7 +728,7 @@ export const openDesktopFileInApp = async ( }; export const filterInstalledDesktopApps = async (apps: string[]): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return []; } @@ -760,19 +738,18 @@ export const filterInstalledDesktopApps = async (apps: string[]): Promise('desktop_filter_installed_apps', { apps: candidate, }); return Array.isArray(result) ? result.filter((value) => typeof value === 'string') : []; } catch (error) { - console.warn('Failed to check installed apps (tauri)', error); + console.warn('Failed to check installed apps', error); return []; } }; export const fetchDesktopAppIcons = async (apps: string[]): Promise> => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return {}; } @@ -782,8 +759,7 @@ export const fetchDesktopAppIcons = async (apps: string[]): Promise('desktop_fetch_app_icons', { apps: candidate, }); if (!Array.isArray(result)) { @@ -798,7 +774,7 @@ export const fetchDesktopAppIcons = async (apps: string[]): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return { apps: [], success: false, hasCache: false, isCacheStale: false }; } @@ -829,8 +805,7 @@ export const fetchDesktopInstalledApps = async ( } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - const result = await tauri?.core?.invoke?.('desktop_get_installed_apps', { + const result = await invokeDesktop('desktop_get_installed_apps', { apps: candidate, force: force === true ? true : undefined, }); @@ -858,19 +833,18 @@ export const fetchDesktopInstalledApps = async ( isCacheStale: payload.isCacheStale === true, }; } catch (error) { - console.warn('Failed to fetch installed apps (tauri)', error); + console.warn('Failed to fetch installed apps', error); return { apps: [], success: false, hasCache: false, isCacheStale: false }; } }; export const clearDesktopCache = async (): Promise => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { return false; } try { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - await tauri?.core?.invoke?.('desktop_clear_cache'); + await invokeDesktop('desktop_clear_cache'); return true; } catch (error) { console.warn('Failed to clear cache', error); diff --git a/packages/ui/src/lib/desktopBoot.test.ts b/packages/ui/src/lib/desktopBoot.test.ts index 8efb5590..9ab78c87 100644 --- a/packages/ui/src/lib/desktopBoot.test.ts +++ b/packages/ui/src/lib/desktopBoot.test.ts @@ -194,7 +194,7 @@ describe('shouldRestartDesktopBootFlow', () => { test('restarts the desktop app when boot UI is running in the startup window', () => { expect( shouldRestartDesktopBootFlow({ - isTauriShell: true, + isDesktopShell: true, isDesktopLocalOriginActive: false, }), ).toBe(true); @@ -203,16 +203,16 @@ describe('shouldRestartDesktopBootFlow', () => { test('does not restart when the local desktop origin is already active', () => { expect( shouldRestartDesktopBootFlow({ - isTauriShell: true, + isDesktopShell: true, isDesktopLocalOriginActive: true, }), ).toBe(false); }); - test('does not restart outside the tauri shell', () => { + test('does not restart outside the desktop shell', () => { expect( shouldRestartDesktopBootFlow({ - isTauriShell: false, + isDesktopShell: false, isDesktopLocalOriginActive: false, }), ).toBe(false); diff --git a/packages/ui/src/lib/desktopBoot.ts b/packages/ui/src/lib/desktopBoot.ts index ca9d2405..7c1d0880 100644 --- a/packages/ui/src/lib/desktopBoot.ts +++ b/packages/ui/src/lib/desktopBoot.ts @@ -222,14 +222,14 @@ export type InitialLoadingState = { }; export type DesktopBootFlowRestartInput = { - isTauriShell: boolean; + isDesktopShell: boolean; isDesktopLocalOriginActive: boolean; }; /** * Whether the initial loading screen can be dismissed. * - * Desktop shells must wait until a valid boot outcome is injected by Rust. + * Desktop shells must wait until a valid boot outcome is injected by the native host. * For non-main views (chooser, recovery), the splash can dismiss as soon as * the outcome is known — `isInitialized` is not required because OpenCode * may not be available in those flows. @@ -254,16 +254,16 @@ export function canDismissInitialLoading(state: InitialLoadingState): boolean { } /** - * Boot/recovery UI can render in the Tauri startup window before the local + * Boot/recovery UI can render in the desktop startup window before the local * desktop HTTP origin is active. In that state, same-origin reloads and - * `/api/*` requests cannot recover the app, so callers must restart Tauri. + * `/api/*` requests cannot recover the app, so callers must restart desktop. */ export function shouldRestartDesktopBootFlow(input: DesktopBootFlowRestartInput): boolean { - return input.isTauriShell && !input.isDesktopLocalOriginActive; + return input.isDesktopShell && !input.isDesktopLocalOriginActive; } /** - * Read the boot outcome injected by the Rust backend. + * Read the boot outcome injected by the native desktop host. * Returns `null` when not in desktop, when the outcome has not been set yet, * or when the injected payload is malformed. */ diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index 459d5d32..5dc39e96 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -1,12 +1,6 @@ -import { isTauriShell } from '@/lib/desktop'; +import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop'; -type TauriInvoke = (cmd: string, args?: Record) => Promise; - -type TauriGlobal = { - core?: { - invoke?: TauriInvoke; - }; -}; +type DesktopInvoke = (cmd: string, args?: Record) => Promise; export type DesktopHost = { id: string; @@ -176,10 +170,9 @@ export const getDesktopHostApiUrl = (host: DesktopHost): string => { return normalizeHostUrl(host.apiUrl || host.url) || host.apiUrl || host.url; }; -const getInvoke = (): TauriInvoke | null => { - if (!isTauriShell()) return null; - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null; +const getInvoke = (): DesktopInvoke | null => { + if (!hasDesktopInvoke()) return null; + return (command, args) => invokeDesktop(command, args) as Promise; }; export const desktopHostsGet = async (): Promise => { diff --git a/packages/ui/src/lib/desktopNative.ts b/packages/ui/src/lib/desktopNative.ts index 62d3df1a..5b3be37f 100644 --- a/packages/ui/src/lib/desktopNative.ts +++ b/packages/ui/src/lib/desktopNative.ts @@ -1,30 +1,15 @@ -import { isDesktopShell } from '@/lib/desktop'; +import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop'; type InvokeArgs = Record; -const isElectronDesktop = (): boolean => { - return typeof window !== 'undefined' && Boolean((window as { __OPENCHAMBER_ELECTRON__?: unknown }).__OPENCHAMBER_ELECTRON__); -}; - -const getInvoke = () => { - if (typeof window === 'undefined') { - return null; - } - const tauri = (window as unknown as { - __TAURI__?: { core?: { invoke?: (cmd: string, args?: InvokeArgs) => Promise } }; - }).__TAURI__; - return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null; -}; - export const invokeDesktopCommand = async ( command: string, args?: InvokeArgs, ): Promise => { - const invoke = getInvoke(); - if (!invoke) { + if (!hasDesktopInvoke()) { throw new Error('Desktop runtime is not available'); } - return invoke(command, args) as Promise; + return invokeDesktop(command, args) as Promise; }; export const startDesktopWindowDrag = async (): Promise => { @@ -33,13 +18,7 @@ export const startDesktopWindowDrag = async (): Promise => { } try { - if (isElectronDesktop()) { - await invokeDesktopCommand('desktop_start_window_drag'); - return; - } - - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - await getCurrentWindow().startDragging(); + await invokeDesktopCommand('desktop_start_window_drag'); } catch { // ignore } @@ -51,11 +30,6 @@ export const isDesktopWindowFullscreen = async (): Promise => { } try { - if (!isElectronDesktop()) { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - return await getCurrentWindow().isFullscreen(); - } - return Boolean(await invokeDesktopCommand('desktop_is_window_fullscreen')); } catch { return false; @@ -77,12 +51,6 @@ export const setDesktopWindowTitle = async (title: string): Promise => { } try { - if (!isElectronDesktop()) { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - await getCurrentWindow().setTitle(title); - return; - } - await invokeDesktopCommand('desktop_set_window_title', { title }); } catch { // ignore @@ -98,12 +66,6 @@ export const setDesktopWindowTheme = async ( } try { - if (!isElectronDesktop()) { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('desktop_set_window_theme', { themeMode, themeVariant }); - return; - } - await invokeDesktopCommand('desktop_set_window_theme', { themeMode, themeVariant }); } catch { // ignore @@ -116,11 +78,6 @@ export const getDesktopAppVersion = async (): Promise => { } try { - if (!isElectronDesktop()) { - const { getVersion } = await import('@tauri-apps/api/app'); - return await getVersion(); - } - const version = await invokeDesktopCommand('desktop_get_app_version'); return typeof version === 'string' && version.trim().length > 0 ? version : null; } catch { @@ -146,17 +103,6 @@ export const listenDesktopNativeDragDrop = async ( return null; } - // Electron uses the renderer's native DOM drag/drop events instead of a - // separate webview drag listener. - if (isElectronDesktop()) { - return null; - } - - try { - const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow'); - const webviewWindow = getCurrentWebviewWindow(); - return await webviewWindow.onDragDropEvent(handler as never); - } catch { - return null; - } + void handler; + return null; }; diff --git a/packages/ui/src/lib/desktopSsh.ts b/packages/ui/src/lib/desktopSsh.ts index f094a689..a05f598f 100644 --- a/packages/ui/src/lib/desktopSsh.ts +++ b/packages/ui/src/lib/desktopSsh.ts @@ -1,17 +1,12 @@ -import { isTauriShell } from '@/lib/desktop'; +import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop'; -type TauriInvoke = (cmd: string, args?: Record) => Promise; +type DesktopInvoke = (cmd: string, args?: Record) => Promise; -type TauriGlobal = { - core?: { - invoke?: TauriInvoke; - }; - event?: { - listen?: ( - event: string, - handler: (evt: { payload?: unknown }) => void, - ) => Promise<() => void>; - }; +type DesktopBridgeGlobal = { + listen?: ( + event: string, + handler: (evt: { payload?: unknown }) => void, + ) => Promise<() => void>; }; export type DesktopSshRemoteMode = 'managed' | 'external'; @@ -126,10 +121,9 @@ const asStringArray = (value: unknown): string[] => { return value.filter((item): item is string => typeof item === 'string'); }; -const getInvoke = (): TauriInvoke | null => { - if (!isTauriShell()) return null; - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null; +const getInvoke = (): DesktopInvoke | null => { + if (!hasDesktopInvoke()) return null; + return (command, args) => invokeDesktop(command, args) as Promise; }; const parseStoredSecret = (value: unknown): DesktopSshStoredSecret | undefined => { @@ -432,12 +426,12 @@ export const desktopSshLogsClear = async (id: string): Promise => { export const listenDesktopSshStatus = async ( listener: (status: DesktopSshInstanceStatus) => void, ): Promise<() => Promise> => { - if (!isTauriShell()) { + if (!hasDesktopInvoke()) { return async () => {}; } - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - const listen = tauri?.event?.listen; + const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__; + const listen = desktop?.listen; if (typeof listen !== 'function') { return async () => {}; } diff --git a/packages/ui/src/lib/device.ts b/packages/ui/src/lib/device.ts index b6b0b7c7..267c8c4b 100644 --- a/packages/ui/src/lib/device.ts +++ b/packages/ui/src/lib/device.ts @@ -45,7 +45,7 @@ const getNavigatorDeviceHints = (maxTouchPoints: number) => { }; const setRootDeviceAttributes = ( - isTauriShellRuntime: boolean, + isDesktopShellRuntime: boolean, deviceType: DeviceType, hasTouchInput: boolean, ) => { @@ -66,7 +66,7 @@ const setRootDeviceAttributes = ( : 'device-desktop' ); - if (isTauriShellRuntime) { + if (isDesktopShellRuntime) { root.classList.add('desktop-runtime'); root.style.setProperty('--is-mobile', '0'); root.style.setProperty('--device-type', 'desktop'); diff --git a/packages/ui/src/lib/ime.ts b/packages/ui/src/lib/ime.ts index ed2bb77e..55055905 100644 --- a/packages/ui/src/lib/ime.ts +++ b/packages/ui/src/lib/ime.ts @@ -5,10 +5,9 @@ import type React from 'react'; * Uses both `isComposing` and the `keyCode === 229` fallback. * * Note: `keyCode` is deprecated, but `229` remains a practical fallback for - * some WebKit-based environments (including Tauri WebView) where composition + * some WebKit-based environments where composition * events can be ordered unexpectedly. */ export const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => { return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229; }; - diff --git a/packages/ui/src/lib/preview/screenshot-capture.ts b/packages/ui/src/lib/preview/screenshot-capture.ts index eb99f0c3..a587a943 100644 --- a/packages/ui/src/lib/preview/screenshot-capture.ts +++ b/packages/ui/src/lib/preview/screenshot-capture.ts @@ -1,5 +1,6 @@ import { snapdom } from '@zumer/snapdom'; import { getFontEmbedCSS, toJpeg } from 'html-to-image'; +import { invokeDesktop } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; export type PreviewElementMetadata = { @@ -97,18 +98,16 @@ export const renderPreviewScreenshot = async ( iframe: HTMLIFrameElement, target: PreviewElementMetadata, ): Promise => { - const tauri = typeof window !== 'undefined' - ? (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record) => Promise } } }).__TAURI__ - : undefined; - if (typeof tauri?.core?.invoke === 'function') { + if (typeof window !== 'undefined') { try { const rect = iframe.getBoundingClientRect(); - const capture = await tauri.core.invoke<{ mime: string; base64: string; width: number; height: number }>('desktop_capture_page_rect', { + const capture = await invokeDesktop<{ mime: string; base64: string; width: number; height: number }>('desktop_capture_page_rect', { x: rect.left, y: rect.top, width: rect.width, height: rect.height, }); + if (!capture) throw new Error('Desktop screenshot capture is not available'); const image = new Image(); await new Promise((resolve, reject) => { image.onload = () => resolve(); diff --git a/packages/ui/src/lib/router/index.ts b/packages/ui/src/lib/router/index.ts index 40571d88..896636c2 100644 --- a/packages/ui/src/lib/router/index.ts +++ b/packages/ui/src/lib/router/index.ts @@ -2,7 +2,7 @@ * Router module for URL-based navigation in OpenChamber. * * Provides bidirectional sync between URL query parameters and application state. - * Works across web, desktop (Tauri), and VS Code (state-only mode). + * Works across web, desktop, and VS Code (state-only mode). * * URL Schema: * - `?session=` - Navigate to specific session diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 9f318ebd..2ebe9cc0 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -1,5 +1,5 @@ import { isMacOS } from '@/lib/utils'; -import { isTauriShell } from '@/lib/desktop'; +import { isDesktopShell } from '@/lib/desktop'; export type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl'; export type ShortcutKey = string; @@ -32,7 +32,7 @@ const MODIFIER_KEY_MAP: Record = { }; const DISPLAY_LABEL_MAP: Record = { - 'mod': isMacOS() && isTauriShell() ? '⌘' : 'Ctrl', + 'mod': isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl', 'shift': '⇧', 'alt': '⌥', 'option': '⌥', @@ -555,7 +555,7 @@ export function eventMatchesShortcut( const expectedShift = parsed.modifiers.has('shift'); const expectedAlt = parsed.modifiers.has('alt'); const expectedCtrl = parsed.modifiers.has('ctrl'); - const isDesktopMac = isMacOS() && isTauriShell(); + const isDesktopMac = isMacOS() && isDesktopShell(); const isMac = isMacOS(); const modMatches = isDesktopMac @@ -615,5 +615,5 @@ export function getShortcutLabel(id: string): string { } export function getModifierLabel(): string { - return isMacOS() && isTauriShell() ? '⌘' : 'Ctrl'; + return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl'; } diff --git a/packages/ui/src/lib/url.ts b/packages/ui/src/lib/url.ts index e052968f..0e688c97 100644 --- a/packages/ui/src/lib/url.ts +++ b/packages/ui/src/lib/url.ts @@ -1,15 +1,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -/** - * Utility for opening external URLs with Tauri shell support. - * In desktop runtime, uses tauri.shell.open() for proper system browser handling. - * Falls back to window.open() for web runtime. - */ - -type TauriShell = { - shell?: { - open?: (url: string) => Promise; - }; +type DesktopBridgeGlobal = { + openExternal?: (url: string) => Promise; }; const parseUrlSafely = (value: string): URL | null => { @@ -90,7 +82,7 @@ export const extractLoopbackUrls = (text: string): string[] => { /** * Opens an external URL in the system browser. - * In Tauri desktop runtime, uses tauri.shell.open() for proper handling. + * In desktop runtime, uses the native shell for proper handling. * Falls back to window.open() for web runtime. * * @param url - The URL to open @@ -127,10 +119,10 @@ export const openExternalUrl = async (url: string): Promise => { } } - const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; - if (tauri?.shell?.open) { + const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__; + if (desktop?.openExternal) { try { - await tauri.shell.open(normalizedTarget); + await desktop.openExternal(normalizedTarget); return true; } catch { // Fall through to window.open diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index b856d146..fe7f1087 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -1,6 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -import { isTauriShell } from "@/lib/desktop"; +import { isDesktopShell } from "@/lib/desktop"; import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch"; import type { I18nKey } from "@/lib/i18n"; @@ -31,19 +31,19 @@ export const getRevealLabelKey = (): I18nKey => { /** * Checks if the platform-appropriate modifier key is pressed. * On macOS desktop app: Cmd (metaKey), on other platforms or web: Ctrl (ctrlKey). - * Browser intercepts Cmd shortcuts, so we only use Cmd in Tauri desktop app. + * Browser intercepts Cmd shortcuts, so we only use Cmd in the desktop app. */ export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => { - return isMacOS() && isTauriShell() ? e.metaKey : e.ctrlKey; + return isMacOS() && isDesktopShell() ? e.metaKey : e.ctrlKey; }; /** * Returns the platform-appropriate modifier key label. * On macOS desktop app: "⌘", on other platforms or web: "Ctrl" - * Browser intercepts Cmd shortcuts, so we only show Cmd in Tauri desktop app. + * Browser intercepts Cmd shortcuts, so we only show Cmd in the desktop app. */ export const getModifierLabel = (): string => { - return isMacOS() && isTauriShell() ? '⌘' : 'Ctrl'; + return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl'; }; export const truncatePathMiddle = ( diff --git a/packages/ui/src/stores/useOpenInAppsStore.ts b/packages/ui/src/stores/useOpenInAppsStore.ts index 07ed395b..3fd4e2dd 100644 --- a/packages/ui/src/stores/useOpenInAppsStore.ts +++ b/packages/ui/src/stores/useOpenInAppsStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; +import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, getPlatformOpenInApp, type OpenInApp } from '@/lib/openInApps'; import { updateDesktopSettings } from '@/lib/persistence'; @@ -86,7 +86,7 @@ export const useOpenInAppsStore = create()((set, get) => ({ }; const loadInstalledApps = async (force?: boolean) => { - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!isDesktopShell() || !isDesktopLocalOriginActive()) { return; } @@ -213,7 +213,7 @@ export const useOpenInAppsStore = create()((set, get) => ({ get().initialize(); } - if (!isTauriShell() || !isDesktopLocalOriginActive()) { + if (!isDesktopShell() || !isDesktopLocalOriginActive()) { return; } diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts index 88d0f86d..97ca4766 100644 --- a/packages/ui/src/stores/useUpdateStore.ts +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -8,7 +8,6 @@ import { restartToApplyUpdate, isDesktopLocalOriginActive, isElectronShell, - isTauriShell, isVSCodeRuntime, isWebRuntime, } from '@/lib/desktop'; @@ -83,7 +82,7 @@ function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams { params.set('arch', detectArch()); params.set('platform', detectPlatform()); if (runtime === 'desktop') { - params.set('appType', isElectronShell() ? 'desktop-electron' : 'desktop-tauri'); + params.set('appType', 'desktop-electron'); params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote'); return params; } @@ -136,7 +135,7 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin } function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null { - if (isTauriShell()) { + if (isElectronShell()) { return 'desktop'; } if (isVSCodeRuntime()) return 'vscode'; diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index 9dc8e6ce..635fe497 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -51,8 +51,8 @@ const getOpenChamberConfigDir = (): string => { return path.join(os.homedir(), '.config', 'openchamber'); }; -const sanitizeInstallScope = (scope: string): 'desktop-tauri' | 'vscode' | 'web' => { - if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope; +const sanitizeInstallScope = (scope: string): 'vscode' | 'web' => { + if (scope === 'vscode' || scope === 'web') return scope; return 'web'; }; diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 1b429779..d56e92a2 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -683,7 +683,7 @@ export async function activate(context: vscode.ExtensionContext) { sessionEditorProvider?.updateConnectionStatus(status, error); // Start/stop global event watcher based on connection status - // Mirrors web server and desktop Tauri behavior + // Mirrors web server and desktop behavior if (status === 'connected' && chatViewProvider && openCodeManager) { setChatViewProvider(chatViewProvider); void startGlobalEventWatcher(openCodeManager, chatViewProvider); diff --git a/packages/vscode/src/sessionActivityWatcher.ts b/packages/vscode/src/sessionActivityWatcher.ts index 3c0a2c42..61d4dbc2 100644 --- a/packages/vscode/src/sessionActivityWatcher.ts +++ b/packages/vscode/src/sessionActivityWatcher.ts @@ -1,7 +1,7 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import type { OpenCodeManager } from './opencode'; -// Session activity tracking (mirrors web server and desktop Tauri behavior) +// Session activity tracking (mirrors web server and desktop behavior) type ActivityPhase = 'idle' | 'busy' | 'cooldown'; interface SessionActivity { diff --git a/packages/web/server/lib/notifications/emitter-runtime.js b/packages/web/server/lib/notifications/emitter-runtime.js index 67210e7a..ae302912 100644 --- a/packages/web/server/lib/notifications/emitter-runtime.js +++ b/packages/web/server/lib/notifications/emitter-runtime.js @@ -46,8 +46,7 @@ export const createNotificationEmitterRuntime = (dependencies) => { } try { - // stdout IPC: Tauri shell spawns this process as a sidecar and parses - // its stdout for the one-line `${prefix}{json}` protocol. + // stdout fallback for runtimes that parse the one-line `${prefix}{json}` protocol. process.stdout.write(`${desktopNotifyPrefix}${JSON.stringify(payload)}\n`); } catch { // ignore @@ -64,9 +63,9 @@ export const createNotificationEmitterRuntime = (dependencies) => { type: 'openchamber:notification', properties: { ...payload, - // Tell the UI whether the sidecar stdout notification channel is active. + // Tell the UI whether the stdout notification channel is active. // When true, the desktop UI should skip this SSE notification to avoid duplicates. - // When false (e.g. tauri dev), the UI must handle this SSE notification itself. + // When false, the UI must handle this SSE notification itself. desktopStdoutActive: desktopNotifyEnabled, }, }; diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index 4fb390f6..08efec9a 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -29,7 +29,7 @@ function getOpenChamberConfigDir() { } function sanitizeInstallScope(scope) { - if (scope === 'desktop-electron' || scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope; + if (scope === 'desktop-electron' || scope === 'vscode' || scope === 'web') return scope; return 'web'; } @@ -65,7 +65,7 @@ function mapArch(value) { } function normalizeAppType(value) { - if (value === 'web' || value === 'desktop-electron' || value === 'desktop-tauri' || value === 'vscode') return value; + if (value === 'web' || value === 'desktop-electron' || value === 'vscode') return value; return 'web'; } diff --git a/packages/web/server/lib/package-manager.test.js b/packages/web/server/lib/package-manager.test.js index 3eef72e9..7edab852 100644 --- a/packages/web/server/lib/package-manager.test.js +++ b/packages/web/server/lib/package-manager.test.js @@ -115,27 +115,6 @@ describe('checkForUpdates', () => { expect(result.available).toBe(false); }); - it('does not cross-check desktop update claims against npm', async () => { - fetchMock - .when('api.openchamber.dev', { - ok: true, - json: async () => ({ - latestVersion: '1.10.0', - updateAvailable: true, - releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature', - }), - }); - - const result = await checkForUpdates({ - appType: 'desktop-tauri', - currentVersion: '1.9.10', - }); - - expect(result.available).toBe(true); - expect(result.version).toBe('1.10.0'); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - it('accepts electron desktop update claims without npm cross-checking', async () => { fetchMock .when('api.openchamber.dev', { diff --git a/packages/web/src/api/notifications.ts b/packages/web/src/api/notifications.ts index 006e30ab..6dd2f814 100644 --- a/packages/web/src/api/notifications.ts +++ b/packages/web/src/api/notifications.ts @@ -184,13 +184,13 @@ const notifyWithDesktop = async (payload?: NotificationPayload): Promise ({ }, canNotify: () => { if (typeof window !== 'undefined') { - const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; - if (tauri?.core?.invoke) { + const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__; + if (desktop?.invoke) { return true; } } return typeof Notification !== 'undefined' ? Notification.permission === 'granted' : false; }, }); -type TauriGlobal = { - core?: { - invoke?: (cmd: string, args?: Record) => Promise; - }; +type DesktopBridgeGlobal = { + invoke?: (cmd: string, args?: Record) => Promise; }; diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs old mode 100644 new mode 100755 index b3dec753..ff621862 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -10,15 +10,10 @@ const PACKAGES = [ 'package.json', 'packages/ui/package.json', 'packages/web/package.json', - 'packages/desktop/package.json', 'packages/electron/package.json', 'packages/vscode/package.json', ]; -const TAURI_CONF = 'packages/desktop/src-tauri/tauri.conf.json'; -const CARGO_TOML = 'packages/desktop/src-tauri/Cargo.toml'; -const CARGO_LOCK = 'packages/desktop/src-tauri/Cargo.lock'; - const newVersion = process.argv[2]; if (!newVersion || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(newVersion)) { console.error('Usage: node scripts/bump-version.mjs '); @@ -29,7 +24,6 @@ if (!newVersion || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(newVersion)) { console.log(`Bumping version to ${newVersion}\n`); -// Update package.json files for (const pkgPath of PACKAGES) { const fullPath = path.join(ROOT, pkgPath); const pkg = JSON.parse(fs.readFileSync(fullPath, 'utf8')); @@ -39,60 +33,4 @@ for (const pkgPath of PACKAGES) { console.log(` ${pkgPath}: ${oldVersion} -> ${newVersion}`); } -// Update tauri.conf.json -const tauriConfPath = path.join(ROOT, TAURI_CONF); -const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf8')); -const oldTauriVersion = tauriConf.version; -tauriConf.version = newVersion; -fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n'); -console.log(` ${TAURI_CONF}: ${oldTauriVersion} -> ${newVersion}`); - -// Update Cargo.toml -const cargoPath = path.join(ROOT, CARGO_TOML); -let cargoContent = fs.readFileSync(cargoPath, 'utf8'); -const cargoMatch = cargoContent.match(/^version = "(.*)"/m); -const oldCargoVersion = cargoMatch ? cargoMatch[1] : 'unknown'; -cargoContent = cargoContent.replace( - /^version = ".*"$/m, - `version = "${newVersion}"` -); -fs.writeFileSync(cargoPath, cargoContent); -console.log(` ${CARGO_TOML}: ${oldCargoVersion} -> ${newVersion}`); - -// Update Cargo.lock for openchamber-desktop, if present -const cargoLockPath = path.join(ROOT, CARGO_LOCK); -if (fs.existsSync(cargoLockPath)) { - try { - let lockContent = fs.readFileSync(cargoLockPath, 'utf8'); - const anchor = 'name = "openchamber-desktop"'; - const anchorIndex = lockContent.indexOf(anchor); - if (anchorIndex !== -1) { - // find the next version line after the anchor - const verIndex = lockContent.indexOf('version', anchorIndex); - if (verIndex !== -1) { - const q1 = lockContent.indexOf('"', verIndex); - const q2 = lockContent.indexOf('"', q1 + 1); - const oldLockVersion = lockContent.substring(q1 + 1, q2); - lockContent = lockContent.substring(0, q1 + 1) + newVersion + lockContent.substring(q2); - fs.writeFileSync(cargoLockPath, lockContent); - console.log(` ${CARGO_LOCK}: ${oldLockVersion} -> ${newVersion}`); - } else { - console.warn(`Warning: could not locate version line in ${CARGO_LOCK}`); - } - } else { - console.warn(`Warning: could not find openchamber-desktop entry in ${CARGO_LOCK}`); - } - } catch (e) { - console.error(`Failed to update ${CARGO_LOCK}:`, e); - } -} else { - // No lock file to update; ignore gracefully - console.log(`Cargo.lock not found at ${CARGO_LOCK}, skipping lock update`); -} - -console.log(`\nVersion bumped to ${newVersion}`); -console.log('\nNext steps:'); -console.log(` git add -A`); -console.log(` git commit -m "release v${newVersion}"`); -console.log(` git tag v${newVersion}`); -console.log(` git push origin main --tags`); +console.log('\nVersion bump complete. Review changes, then commit and tag.'); diff --git a/tsconfig.json b/tsconfig.json index d74e8a23..d7898f2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "references": [ { "path": "./packages/ui/tsconfig.json" }, { "path": "./packages/web/tsconfig.json" }, - { "path": "./packages/desktop/tsconfig.json" } + { "path": "./packages/electron/tsconfig.json" } ], "compilerOptions": { "baseUrl": ".", @@ -14,8 +14,7 @@ "resolveJsonModule": true, "paths": { "@openchamber/ui/*": ["./packages/ui/src/*"], - "@openchamber/web/*": ["./packages/web/src/*"], - "@openchamber/desktop/*": ["./packages/desktop/src/*"] + "@openchamber/web/*": ["./packages/web/src/*"] } } }