From 137ba044c15895943a9a6e9089c42903a52720dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 3 Sep 2026 23:27:38 +0200 Subject: [PATCH 1/9] feat(remote-control): replace remote access with outbound relay Replace the legacy URL/LAN remote access model with an outbound-only Remote Control flow. Users can now pair another browser with a ten-minute link or QR code and revoke its 30-day device credential from the host settings. Add a shared multiplexed HTTP/WebSocket protocol and a Cloudflare Worker backed by one Durable Object per random host identity. The connector authenticates with a bearer secret, validates an explicit protocol handshake, strips remote credentials, injects a dedicated internal CodeNomad session, bounds pre-handshake queues, and cancels abandoned requests. CodeNomad and OpenCode remain loopback-only behind existing workspace, Git, Yolo, and proxy authorization boundaries. Remove the superseded remote proxy, saved server profiles, LAN discovery, remote native windows, certificate bypasses, and related capabilities. Electron and Tauri now keep the backend alive after the final window closes only while Remote Control is enabled. Cover identity persistence, local-only management, relay security, binary payloads, packaging closure, and desktop lifecycle behavior. Validate the full UI, server, Electron, Tauri, relay, and protocol suites plus a local Wrangler end-to-end pairing and proxy flow. --- .github/workflows/pr-build.yml | 13 + .../references/desktop-conventions.md | 2 +- MIGRATION_V2.md | 2 +- README.md | 6 +- dev-docs/DEVELOPER_MODE.md | 4 +- dev-docs/architecture.md | 17 +- package-lock.json | 17 +- package.json | 7 +- packages/cloudflare/package-lock.json | 545 +++++++++ packages/cloudflare/package.json | 12 +- packages/cloudflare/src/index.test.ts | 36 + packages/cloudflare/src/index.ts | 115 +- .../cloudflare/src/remote-control/headers.ts | 51 + .../src/remote-control/host-object.ts | 463 +++++++ .../src/remote-control/security.test.ts | 23 + .../cloudflare/src/remote-control/security.ts | 39 + packages/cloudflare/tsconfig.json | 14 + packages/cloudflare/wrangler.toml | 19 + packages/electron-app/electron/main/ipc.ts | 15 - packages/electron-app/electron/main/main.ts | 116 +- .../main/multiwindow-lifecycle.test.ts | 40 +- .../electron/main/multiwindow-lifecycle.ts | 47 +- .../electron/main/navigation-security.test.ts | 2 +- .../electron/main/process-manager.ts | 91 +- .../main/remote-window-registry.test.ts | 139 --- .../electron/main/remote-window-registry.ts | 99 -- .../electron/main/startup.test.ts | 13 +- .../electron-app/electron/main/startup.ts | 14 - .../electron/main/window-navigation.ts | 37 + .../electron-app/electron/preload/index.cjs | 2 - .../electron/preload/index.test.ts | 2 +- packages/electron-app/package.json | 2 +- packages/remote-control-protocol/package.json | 23 + .../remote-control-protocol/src/index.test.ts | 8 + packages/remote-control-protocol/src/index.ts | 87 ++ .../remote-control-protocol/tsconfig.json | 13 + packages/server/README.md | 41 +- packages/server/package.json | 2 + packages/server/src/api-types.ts | 59 +- packages/server/src/config/schema.ts | 1 - packages/server/src/index.ts | 121 +- .../src/remote-control/connector.test.ts | 11 + .../server/src/remote-control/connector.ts | 309 +++++ .../src/remote-control/identity.test.ts | 33 + .../server/src/remote-control/identity.ts | 43 + packages/server/src/remote-control/manager.ts | 144 +++ .../__tests__/listener-base-url.test.ts | 8 +- .../__tests__/network-addresses.test.ts | 94 -- .../src/server/__tests__/remote-proxy.test.ts | 328 ----- packages/server/src/server/http-server.ts | 35 +- .../server/src/server/listener-base-url.ts | 5 - .../server/src/server/network-addresses.ts | 128 -- packages/server/src/server/remote-proxy.ts | 621 ---------- packages/server/src/server/routes/meta.ts | 27 +- .../src/server/routes/remote-control.test.ts | 46 + .../src/server/routes/remote-control.ts | 79 ++ .../server/src/server/routes/remote-proxy.ts | 54 - .../src/server/routes/remote-servers.ts | 166 --- packages/server/src/settings/migrate.ts | 5 - packages/server/src/shutdown.test.ts | 8 +- packages/server/src/shutdown.ts | 4 +- packages/tauri-app/src-tauri/build.rs | 2 - .../src-tauri/capabilities/main-window.json | 2 - .../capabilities/preferences-window.json | 4 +- .../remote-window-notifications.json | 15 - .../src-tauri/gen/schemas/acl-manifests.json | 2 +- .../src-tauri/gen/schemas/capabilities.json | 2 +- .../src-tauri/gen/schemas/desktop-schema.json | 24 - .../src-tauri/gen/schemas/windows-schema.json | 24 - .../needs_local_certificate_install.toml | 11 - .../autogenerated/open_remote_window.toml | 11 - .../tauri-app/src-tauri/src/cli_manager.rs | 139 +-- packages/tauri-app/src-tauri/src/linux_tls.rs | 104 -- packages/tauri-app/src-tauri/src/main.rs | 1075 ++--------------- packages/tauri-app/src-tauri/tauri.conf.json | 3 +- .../src/components/folder-selection-view.tsx | 233 +--- packages/ui/src/components/instance-tabs.tsx | 19 +- .../src/components/remote-access-overlay.tsx | 520 -------- .../src/components/remote-server-dialog.tsx | 80 -- .../ui/src/components/settings-screen.tsx | 15 +- .../remote-access-settings-section.tsx | 487 -------- .../remote-control-settings-section.tsx | 229 ++++ .../settings/saved-remote-servers-card.tsx | 67 - packages/ui/src/lib/api-client.ts | 35 +- .../lib/hooks/use-remote-server-profiles.ts | 77 -- .../lib/i18n/messages/de/folderSelection.ts | 31 - packages/ui/src/lib/i18n/messages/de/index.ts | 4 +- .../src/lib/i18n/messages/de/remoteAccess.ts | 53 - .../src/lib/i18n/messages/de/remoteControl.ts | 26 + .../ui/src/lib/i18n/messages/de/settings.ts | 6 +- .../lib/i18n/messages/en/folderSelection.ts | 31 - packages/ui/src/lib/i18n/messages/en/index.ts | 4 +- .../src/lib/i18n/messages/en/remoteAccess.ts | 53 - .../src/lib/i18n/messages/en/remoteControl.ts | 26 + .../ui/src/lib/i18n/messages/en/settings.ts | 6 +- .../lib/i18n/messages/es/folderSelection.ts | 31 - packages/ui/src/lib/i18n/messages/es/index.ts | 4 +- .../src/lib/i18n/messages/es/remoteAccess.ts | 53 - .../src/lib/i18n/messages/es/remoteControl.ts | 26 + .../ui/src/lib/i18n/messages/es/settings.ts | 6 +- .../lib/i18n/messages/fr/folderSelection.ts | 31 - packages/ui/src/lib/i18n/messages/fr/index.ts | 4 +- .../src/lib/i18n/messages/fr/remoteAccess.ts | 53 - .../src/lib/i18n/messages/fr/remoteControl.ts | 26 + .../ui/src/lib/i18n/messages/fr/settings.ts | 6 +- .../lib/i18n/messages/he/folderSelection.ts | 31 - packages/ui/src/lib/i18n/messages/he/index.ts | 4 +- .../src/lib/i18n/messages/he/remoteAccess.ts | 53 - .../src/lib/i18n/messages/he/remoteControl.ts | 26 + .../ui/src/lib/i18n/messages/he/settings.ts | 6 +- .../lib/i18n/messages/ja/folderSelection.ts | 31 - packages/ui/src/lib/i18n/messages/ja/index.ts | 4 +- .../src/lib/i18n/messages/ja/remoteAccess.ts | 53 - .../src/lib/i18n/messages/ja/remoteControl.ts | 26 + .../ui/src/lib/i18n/messages/ja/settings.ts | 6 +- .../lib/i18n/messages/ne/folderSelection.ts | 31 - packages/ui/src/lib/i18n/messages/ne/index.ts | 4 +- .../src/lib/i18n/messages/ne/remoteAccess.ts | 53 - .../src/lib/i18n/messages/ne/remoteControl.ts | 26 + .../ui/src/lib/i18n/messages/ne/settings.ts | 6 +- .../lib/i18n/messages/ru/folderSelection.ts | 31 - packages/ui/src/lib/i18n/messages/ru/index.ts | 4 +- .../src/lib/i18n/messages/ru/remoteAccess.ts | 53 - .../src/lib/i18n/messages/ru/remoteControl.ts | 26 + .../ui/src/lib/i18n/messages/ru/settings.ts | 6 +- .../lib/i18n/messages/tr/folderSelection.ts | 31 - packages/ui/src/lib/i18n/messages/tr/index.ts | 4 +- .../src/lib/i18n/messages/tr/remoteAccess.ts | 50 - .../src/lib/i18n/messages/tr/remoteControl.ts | 26 + .../ui/src/lib/i18n/messages/tr/settings.ts | 6 +- .../i18n/messages/zh-Hans/folderSelection.ts | 31 - .../ui/src/lib/i18n/messages/zh-Hans/index.ts | 4 +- .../lib/i18n/messages/zh-Hans/remoteAccess.ts | 53 - .../i18n/messages/zh-Hans/remoteControl.ts | 26 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 6 +- packages/ui/src/lib/native/remote-window.ts | 70 -- .../src/lib/remote-access-addresses.test.ts | 17 - .../ui/src/lib/remote-access-addresses.ts | 14 - packages/ui/src/lib/runtime-env.test.ts | 5 +- packages/ui/src/lib/runtime-env.ts | 2 - packages/ui/src/stores/preferences.tsx | 110 +- .../src/styles/components/remote-access.css | 346 ------ .../src/styles/components/remote-control.css | 104 ++ packages/ui/src/styles/controls.css | 2 +- packages/ui/src/types/global.d.ts | 8 - scripts/desktop-server-resources.cjs | 18 +- scripts/desktop-server-resources.test.cjs | 1 + 147 files changed, 3213 insertions(+), 6368 deletions(-) create mode 100644 packages/cloudflare/src/index.test.ts create mode 100644 packages/cloudflare/src/remote-control/headers.ts create mode 100644 packages/cloudflare/src/remote-control/host-object.ts create mode 100644 packages/cloudflare/src/remote-control/security.test.ts create mode 100644 packages/cloudflare/src/remote-control/security.ts create mode 100644 packages/cloudflare/tsconfig.json delete mode 100644 packages/electron-app/electron/main/remote-window-registry.test.ts delete mode 100644 packages/electron-app/electron/main/remote-window-registry.ts create mode 100644 packages/electron-app/electron/main/window-navigation.ts create mode 100644 packages/remote-control-protocol/package.json create mode 100644 packages/remote-control-protocol/src/index.test.ts create mode 100644 packages/remote-control-protocol/src/index.ts create mode 100644 packages/remote-control-protocol/tsconfig.json create mode 100644 packages/server/src/remote-control/connector.test.ts create mode 100644 packages/server/src/remote-control/connector.ts create mode 100644 packages/server/src/remote-control/identity.test.ts create mode 100644 packages/server/src/remote-control/identity.ts create mode 100644 packages/server/src/remote-control/manager.ts delete mode 100644 packages/server/src/server/__tests__/network-addresses.test.ts delete mode 100644 packages/server/src/server/__tests__/remote-proxy.test.ts delete mode 100644 packages/server/src/server/network-addresses.ts delete mode 100644 packages/server/src/server/remote-proxy.ts create mode 100644 packages/server/src/server/routes/remote-control.test.ts create mode 100644 packages/server/src/server/routes/remote-control.ts delete mode 100644 packages/server/src/server/routes/remote-proxy.ts delete mode 100644 packages/server/src/server/routes/remote-servers.ts delete mode 100644 packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json delete mode 100644 packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml delete mode 100644 packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml delete mode 100644 packages/tauri-app/src-tauri/src/linux_tls.rs delete mode 100644 packages/ui/src/components/remote-access-overlay.tsx delete mode 100644 packages/ui/src/components/remote-server-dialog.tsx delete mode 100644 packages/ui/src/components/settings/remote-access-settings-section.tsx create mode 100644 packages/ui/src/components/settings/remote-control-settings-section.tsx delete mode 100644 packages/ui/src/components/settings/saved-remote-servers-card.tsx delete mode 100644 packages/ui/src/lib/hooks/use-remote-server-profiles.ts delete mode 100644 packages/ui/src/lib/i18n/messages/de/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/de/remoteControl.ts delete mode 100644 packages/ui/src/lib/i18n/messages/en/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/en/remoteControl.ts delete mode 100644 packages/ui/src/lib/i18n/messages/es/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/es/remoteControl.ts delete mode 100644 packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/fr/remoteControl.ts delete mode 100644 packages/ui/src/lib/i18n/messages/he/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/he/remoteControl.ts delete mode 100644 packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/ja/remoteControl.ts delete mode 100644 packages/ui/src/lib/i18n/messages/ne/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/ne/remoteControl.ts delete mode 100644 packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/ru/remoteControl.ts delete mode 100644 packages/ui/src/lib/i18n/messages/tr/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/tr/remoteControl.ts delete mode 100644 packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/zh-Hans/remoteControl.ts delete mode 100644 packages/ui/src/lib/native/remote-window.ts delete mode 100644 packages/ui/src/lib/remote-access-addresses.test.ts delete mode 100644 packages/ui/src/lib/remote-access-addresses.ts delete mode 100644 packages/ui/src/styles/components/remote-access.css create mode 100644 packages/ui/src/styles/components/remote-control.css diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 76fa00d00..5a5a65d9a 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -95,9 +95,22 @@ jobs: - name: Install dependencies run: npm ci + - name: Install Remote Control relay dependencies + run: npm ci --prefix packages/cloudflare + - name: Typecheck desktop clients run: npm run typecheck + - name: Typecheck server and Remote Control relay + run: >- + npm run typecheck --workspace @neuralnomads/codenomad && + npm run typecheck --prefix packages/cloudflare + + - name: Test Remote Control protocol and relay + run: >- + npm test --workspace @codenomad/remote-control-protocol && + npm test --prefix packages/cloudflare + - name: Test desktop packaging invariants run: node --test scripts/desktop-server-resources.test.cjs diff --git a/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md index 2378b8cb3..ce30b9940 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md @@ -31,7 +31,7 @@ The desktop process managers start and supervise the CodeNomad backend. They do - Tauri adapter: `packages/ui/src/lib/native/tauri/functions.ts` - Desktop file drop: `packages/ui/src/lib/native/desktop-file-drop.ts` - Client state: `packages/ui/src/lib/native/client-state.ts` -- Remote windows: `packages/ui/src/lib/native/remote-window.ts` +- Remote Control lifecycle: `packages/server/src/remote-control/manager.ts` - Runtime detection: `packages/ui/src/lib/runtime-env.ts` Use these abstractions instead of importing host APIs into feature components. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 1afe1ba95..cd9eba8cc 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -119,7 +119,7 @@ Git status is hybrid: native `vcs.status` is augmented with CodeNomad server det - Translate host/WSL paths only after ownership validation. - Strip CodeNomad cookies, browser authorization, forwarding headers, and incoming `x-opencode-*` headers; inject shared-service authentication server-side. - Block upstream cookies and authentication challenges and avoid logging unredacted secret-bearing request bodies. -- Treat each unguessable preview token as a route-scoped capability. Loopback HTTP native previews use `.preview.localhost` so applications retain normal root paths; HTTPS, LAN, and web clients use the equivalent capability path. SideCar/browser previews remain opaque-origin sandboxes without `allow-same-origin`, and element comments cross a source-checked message bridge. +- Treat each unguessable preview token as a route-scoped capability. Loopback HTTP native previews use `.preview.localhost` so applications retain normal root paths; HTTPS and web clients use the equivalent capability path. SideCar/browser previews remain opaque-origin sandboxes without `allow-same-origin`, and element comments cross a source-checked message bridge. ## Desktop and Restore Restructuring diff --git a/README.md b/README.md index f7d0bb1ed..7f885955e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ CodeNomad transforms OpenCode from a terminal tool into a **premium desktop work ## Features - **🚀 Multi-Instance Workspace** -- **🌐 Remote Access** +- **🌐 Remote Control** through a secure outbound relay, one-time pairing links, and revocable devices - **🧠 Session Management** - **🎙️ Voice Input & Speech** - **🌳 Git Worktrees** @@ -56,7 +56,7 @@ npx @neuralnomads/codenomad --password --launch > **Self-signed certificate:** On first launch with HTTPS enabled (the default), your browser will show a "Your connection is not private" warning. This is expected — the server generates a local self-signed certificate automatically. Click **Advanced → Proceed to localhost** to continue. For local-only use without the warning, run with `--https=false --http=true`. -See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access. +See [Server Documentation](packages/server/README.md) for flags, TLS, authentication, and Remote Control. ### 🧪 Dev Releases @@ -72,7 +72,7 @@ npx @neuralnomads/codenomad-dev --password --launch SideCars let you open local web tools inside CodeNomad as tabs. -Previews use token-scoped URLs inside opaque-origin sandboxes. Loopback HTTP native previews receive a dedicated `.preview.localhost` origin so root routes, POSTs, and live reload behave normally; HTTPS, LAN, and web hosts use the capability path. Element comments use a source-checked message bridge. +Previews use token-scoped URLs inside opaque-origin sandboxes. Loopback HTTP native previews receive a dedicated `.preview.localhost` origin so root routes, POSTs, and live reload behave normally; HTTPS and web clients use the capability path. Element comments use a source-checked message bridge.
Configuration diff --git a/dev-docs/DEVELOPER_MODE.md b/dev-docs/DEVELOPER_MODE.md index d5c8bf644..f37bf5c29 100644 --- a/dev-docs/DEVELOPER_MODE.md +++ b/dev-docs/DEVELOPER_MODE.md @@ -18,7 +18,7 @@ When active, Electron: When active on Windows, Tauri: -- Uses a persistent local `developer-mode` WebView2 directory below the existing channel/config profile without overriding isolated remote-window profiles. +- Uses a persistent local `developer-mode` WebView2 directory below the existing channel/config profile. - Gives only local WebViews `--remote-debugging-port=0`, then reads and verifies their `EBWebView/DevToolsActivePort` endpoint. - Enables Rust backtraces and Node source-map stack traces for the managed backend. @@ -41,7 +41,7 @@ Windows registrations are also discoverable by an OpenCode plugin running in WSL ## Target And Trust Boundaries - The HTTP bridge and CDP endpoint use IPv4 loopback only. The bridge requires its random token. Raw CDP has no authentication, can execute code in authenticated renderer pages, and therefore trusts other processes running as the local user; enable this opt-in mode only on a trusted machine. -- The native host selects the focused local window, or the most-recent local window when CodeNomad is not focused. A focused remote window is never selected. +- The native host selects the focused local window, or the most-recent local window when CodeNomad is not focused. Support windows such as Preferences are never selected. - CDP evaluates a bounded set of page targets and requires exactly the native window UUID, visible `data-instance-id`, and active `data-session-id`. - The bridge resolves the OpenCode session through the shared service and verifies that the visible `data-instance-id` owns that session location. - Operations for one native run are serialized. Click and type revalidate context immediately before input; inspection and screenshot revalidate after capture. Accessibility refs are invalidated by navigation, target replacement, context change, and restart. diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 2d76f1b49..fff3d55da 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -9,6 +9,8 @@ Desktop host -> CodeNomad server -> one shared OpenCode service ^ | | +-> CodeNomad /api/* and /api/events +------ UI clients through /workspaces/:id/instance/api/* + +Paired browser -> Cloudflare Worker/Durable Object <- outbound WebSocket <- CodeNomad server ``` There is no `@opencode-ai/sdk` integration and no legacy `packages/opencode-plugin` package. The narrow project-local Developer Mode adapter is documented in [DEVELOPER_MODE.md](DEVELOPER_MODE.md); it does not own the OpenCode daemon or restore the V1 compatibility runtime. @@ -36,6 +38,14 @@ OpenCode sessions and messages remain shared through the global daemon. Window m Previews use unguessable capabilities for HTTP and WebSocket traffic. Native previews route a token-scoped `.preview.localhost` origin to the pinned target; web clients use the equivalent path route. SideCar/browser frames remain opaque-origin sandboxes without `allow-same-origin`; preview element comments use a source-checked message bridge instead of parent DOM access. +## Remote Control + +CodeNomad listens only on `127.0.0.1`. Remote Control is an outbound-only connection from `packages/server/src/remote-control/` to the Cloudflare Worker and one `RemoteControlHost` Durable Object per random host ID. OpenCode is never exposed directly; relayed requests terminate at CodeNomad and continue through its existing authentication, folder, Git, Yolo, and proxy boundaries. + +The persistent host identity is stored in `remote-control.json` with restricted permissions where supported. The connector authenticates with a bearer secret, while browsers pair through a fragment-token link that expires after ten minutes. The relay stores only token hashes, issues secure host-scoped device cookies for 30 days, and supports revocation. Remote credentials are stripped before forwarding; the local connector injects a dedicated internal CodeNomad session instead. + +HTTP bodies and WebSocket frames share the versioned types in `packages/remote-control-protocol/`. The relay streams HTTP responses, propagates WebSocket subprotocols, bounds pre-handshake queues, cancels abandoned work, and rejects stale responses after a host reconnect. Electron and Tauri keep the backend alive after the final window closes only while Remote Control is enabled. + ## API Boundaries CodeNomad control APIs live under `/api/*`. Important routes include: @@ -44,6 +54,7 @@ CodeNomad control APIs live under `/api/*`. Important routes include: - `/api/workspaces/:id/worktrees/:slug/git-status|git-diff|git-stage|git-unstage|git-commit` - `/api/events` and `/api/client-connections/pong` - `/api/storage`, `/api/settings`, `/api/filesystem`, `/api/speech` +- `/api/remote-control/*`, restricted to local host UI requests - `/api/opencode-plugin/automation`, authenticated by a per-process loopback token and restricted to CodeNomad-owned locations Native OpenCode requests use `/workspaces/:id/instance/api/*`. The Fastify proxy exposes an explicit method/path allowlist, adds shared-service authorization, and rejects locations/directories outside the selected workspace or its worktrees. Session routes also verify `session.location.directory`. Upstream additions require an explicit proxy review and are not available automatically. @@ -72,12 +83,13 @@ Current native events include session lifecycle/output events (`session.created` | Yolo state, persistence and auto-accept | CodeNomad server | | Browser SSE multiplexing | CodeNomad server | | Developer Mode and CDP feedback | Current CodeNomad desktop host and authenticated project-local adapter | +| Remote Control relay, pairing, and device credentials | CodeNomad server plus Cloudflare Worker/Durable Object | Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped native background Shells, refreshes on Shell events/reconnect, displays native metadata, and allows ownership-checked removal. Output requests preserve native cursor pagination. Interactive PTYs remain separate. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored; the project-local Developer Mode adapter is the only reviewed exception. ## Persistence -CodeNomad configuration resolves through `packages/server/src/config/location.ts`: `config.yaml`, `state.yaml`, and `instances/` under `~/.config/codenomad/`. `config.json` is migration input only. +CodeNomad configuration resolves through `packages/server/src/config/location.ts`: `config.yaml`, `state.yaml`, `remote-control.json`, and `instances/` under `~/.config/codenomad/`. `config.json` is migration input only. ## Key Files @@ -89,6 +101,9 @@ CodeNomad configuration resolves through `packages/server/src/config/location.ts - `packages/server/src/workspaces/git-mutations.ts` - `packages/server/src/permissions/auto-accept-manager.ts` - `packages/server/src/opencode/automation-plugin.ts` +- `packages/server/src/remote-control/manager.ts` +- `packages/cloudflare/src/remote-control/host-object.ts` +- `packages/remote-control-protocol/src/index.ts` - `packages/ui/src/lib/sdk-manager.ts` - `packages/ui/src/lib/api-client.ts` - `packages/ui/src/stores/session-api.ts` diff --git a/package-lock.json b/package-lock.json index 40655acc0..7810d6688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "packages/server", "packages/ui", "packages/electron-app", - "packages/tauri-app" + "packages/tauri-app", + "packages/remote-control-protocol" ] } }, @@ -1593,6 +1594,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@codenomad/remote-control-protocol": { + "resolved": "packages/remote-control-protocol", + "link": true + }, "node_modules/@codenomad/tauri-app": { "resolved": "packages/tauri-app", "link": true @@ -14957,11 +14962,21 @@ "dev": true, "license": "MIT" }, + "packages/remote-control-protocol": { + "name": "@codenomad/remote-control-protocol", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "tsx": "^4.20.6", + "typescript": "^5.6.3" + } + }, "packages/server": { "name": "@neuralnomads/codenomad", "version": "0.20.0-dev-v2", "license": "MIT", "dependencies": { + "@codenomad/remote-control-protocol": "0.1.0", "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", diff --git a/package.json b/package.json index 18d8af1d2..4950e4b6b 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,20 @@ "packages/server", "packages/ui", "packages/electron-app", - "packages/tauri-app" + "packages/tauri-app", + "packages/remote-control-protocol" ] }, "scripts": { "dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app", "dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app", "dev:tauri": "npm run dev --workspace @codenomad/tauri-app", - "build": "npm run build --workspace @neuralnomads/codenomad-electron-app", + "build": "npm run build --workspace @codenomad/remote-control-protocol && npm run build --workspace @neuralnomads/codenomad-electron-app", "build:tauri": "npm run build --workspace @codenomad/tauri-app", "build:ui": "npm run build --workspace @codenomad/ui", "build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app", "build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app", - "typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app", + "typecheck": "npm run typecheck --workspace @codenomad/remote-control-protocol && npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app", "bumpVersion": "node ./scripts/bump-version.js" }, "dependencies": { diff --git a/packages/cloudflare/package-lock.json b/packages/cloudflare/package-lock.json index 12b89dffe..699f6b168 100644 --- a/packages/cloudflare/package-lock.json +++ b/packages/cloudflare/package-lock.json @@ -5,10 +5,26 @@ "packages": { "": { "name": "@codenomad/ui-host-worker", + "license": "MIT", + "dependencies": { + "@codenomad/remote-control-protocol": "file:../remote-control-protocol" + }, "devDependencies": { + "@cloudflare/workers-types": "^4.20260702.1", + "tsx": "^4.20.6", + "typescript": "^5.6.3", "wrangler": "^4.0.0" } }, + "../remote-control-protocol": { + "name": "@codenomad/remote-control-protocol", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "tsx": "^4.20.6", + "typescript": "^5.6.3" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", @@ -120,6 +136,18 @@ "node": ">=16" } }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peer": true + }, + "node_modules/@codenomad/remote-control-protocol": { + "resolved": "../remote-control-protocol", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1376,6 +1404,523 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index ec362fce5..00ee5fbba 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -6,10 +6,20 @@ "scripts": { "build:manifest": "node ./scripts/build-manifest.mjs", "release:ui": "node ./scripts/release-ui.mjs", + "predev": "npm run build --prefix ../remote-control-protocol", "dev": "wrangler dev", - "deploy": "wrangler deploy" + "predeploy": "npm run build --prefix ../remote-control-protocol", + "deploy": "wrangler deploy", + "test": "node --import tsx --test src/**/*.test.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codenomad/remote-control-protocol": "file:../remote-control-protocol" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20260702.1", + "tsx": "^4.20.6", + "typescript": "^5.6.3", "wrangler": "^4.0.0" } } diff --git a/packages/cloudflare/src/index.test.ts b/packages/cloudflare/src/index.test.ts new file mode 100644 index 000000000..3952b5a18 --- /dev/null +++ b/packages/cloudflare/src/index.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import test from "node:test" +import worker, { type Env } from "./index" + +function relayEnv(onRequest: (request: Request) => Response | Promise): Env { + const stub = { fetch: onRequest } + return { + REMOTE_BASE_HOST: "remote.example.com", + REMOTE_HOSTS: { + idFromName: (name: string) => name, + get: () => stub, + } as unknown as DurableObjectNamespace, + ASSETS: { fetch: () => new Response("asset") } as Fetcher, + } +} + +test("relay operations use internal headers without changing remote query parameters", async () => { + let forwarded: Request | undefined + const env = relayEnv((request) => { + forwarded = request + return new Response("ok") + }) + const hostId = "a".repeat(32) + const response = await worker.fetch(new Request(`https://${hostId}.remote.example.com/api/items?operation=client&value=1`), env) + assert.equal(response.status, 200) + assert.equal(new URL(forwarded!.url).search, "?operation=client&value=1") + assert.equal(forwarded!.headers.get("x-codenomad-relay-operation"), "proxy") +}) + +test("pairing page allows its same-origin exchange and blocks framing", async () => { + const hostId = "b".repeat(32) + const response = await worker.fetch(new Request(`https://${hostId}.remote.example.com/__codenomad/pair`), relayEnv(() => new Response())) + const policy = response.headers.get("content-security-policy") ?? "" + assert.match(policy, /connect-src 'self'/) + assert.match(policy, /frame-ancestors 'none'/) +}) diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index dbe264587..90ae85c99 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,26 +1,115 @@ +import { RemoteControlHost } from "./remote-control/host-object" +import { HOST_ID_PATTERN } from "./remote-control/security" + +export { RemoteControlHost } + export interface Env { - ASSETS: { fetch: (request: Request) => Promise } + ASSETS: Fetcher + REMOTE_HOSTS: DurableObjectNamespace + REMOTE_BASE_HOST: string } export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url) + const baseHost = env.REMOTE_BASE_HOST.toLowerCase() + const hostId = remoteHostId(url.hostname, baseHost) + + if (hostId) return handleRemoteHost(request, env, hostId) + if (url.hostname.toLowerCase() === baseHost && url.pathname.startsWith("/api/hosts/")) { + return handleHostControl(request, env) + } if (url.pathname === "/version.json") { const response = await env.ASSETS.fetch(request) - - const newHeaders = new Headers(response.headers) - newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") - newHeaders.set("Pragma", "no-cache") - newHeaders.set("Expires", "0") - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: newHeaders, - }) + const headers = new Headers(response.headers) + headers.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + headers.set("Pragma", "no-cache") + headers.set("Expires", "0") + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) } - return env.ASSETS.fetch(request) }, } + +async function handleHostControl(request: Request, env: Env): Promise { + const url = new URL(request.url) + const match = url.pathname.match(/^\/api\/hosts\/([a-f0-9]{32})\/(connect|pair|devices)(?:\/([^/]+))?$/) + if (!match) return Response.json({ error: "Invalid host control path" }, { status: 404 }) + const [, hostId, action, resourceId] = match + const operation = action === "connect" + ? "host-connect" + : action === "pair" + ? "pair-create" + : resourceId + ? "device-revoke" + : "devices" + return hostStub(env, hostId).fetch(withOperation(request, operation, resourceId)) +} + +async function handleRemoteHost(request: Request, env: Env, hostId: string): Promise { + const url = new URL(request.url) + if (url.pathname === "/__codenomad/pair" && request.method === "POST") { + return hostStub(env, hostId).fetch(withOperation(request, "pair-exchange")) + } + + if (url.pathname === "/__codenomad/pair" && request.method === "GET") { + return new Response(pairingPage(request.headers.get("accept-language")), { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + "Referrer-Policy": "no-referrer", + }, + }) + } + + return hostStub(env, hostId).fetch(withOperation(request, "proxy")) +} + +function hostStub(env: Env, hostId: string): DurableObjectStub { + return env.REMOTE_HOSTS.get(env.REMOTE_HOSTS.idFromName(hostId)) +} + +function withOperation(request: Request, operation: string, resourceId?: string): Request { + const headers = new Headers(request.headers) + headers.set("X-CodeNomad-Relay-Operation", operation) + if (resourceId) headers.set("X-CodeNomad-Relay-Device-Id", resourceId) + return new Request(request, { headers }) +} + +function remoteHostId(hostname: string, baseHost: string): string | null { + const suffix = `.${baseHost}` + const normalized = hostname.toLowerCase() + if (!normalized.endsWith(suffix)) return null + const value = normalized.slice(0, -suffix.length) + return HOST_ID_PATTERN.test(value) ? value : null +} + +const pairingMessages = { + de: { connecting: "Dieses Gerät wird gekoppelt…", failed: "Kopplung fehlgeschlagen", mobile: "Mobilgerät", browser: "Webbrowser" }, + en: { connecting: "Pairing this device…", failed: "Pairing failed", mobile: "Mobile device", browser: "Web browser" }, + es: { connecting: "Vinculando este dispositivo…", failed: "Error de vinculación", mobile: "Dispositivo móvil", browser: "Navegador web" }, + fr: { connecting: "Appairage de cet appareil…", failed: "Échec de l’appairage", mobile: "Appareil mobile", browser: "Navigateur web" }, + he: { connecting: "המכשיר מצומד…", failed: "הצימוד נכשל", mobile: "מכשיר נייד", browser: "דפדפן" }, + ja: { connecting: "この端末をペアリング中…", failed: "ペアリングに失敗しました", mobile: "モバイル端末", browser: "ウェブブラウザー" }, + ne: { connecting: "यो उपकरण जोडी हुँदैछ…", failed: "जोडी असफल भयो", mobile: "मोबाइल उपकरण", browser: "वेब ब्राउजर" }, + ru: { connecting: "Подключение устройства…", failed: "Не удалось выполнить сопряжение", mobile: "Мобильное устройство", browser: "Веб-браузер" }, + tr: { connecting: "Bu cihaz eşleştiriliyor…", failed: "Eşleştirme başarısız", mobile: "Mobil cihaz", browser: "Web tarayıcısı" }, + "zh-Hans": { connecting: "正在配对此设备…", failed: "配对失败", mobile: "移动设备", browser: "网页浏览器" }, +} as const + +function pairingPage(acceptLanguage: string | null): string { + const requested = (acceptLanguage ?? "").toLowerCase() + const locale = Object.keys(pairingMessages).find((candidate) => requested.startsWith(candidate.toLowerCase()) + || requested.includes(`,${candidate.toLowerCase()}`)) as keyof typeof pairingMessages | undefined + const messages = JSON.stringify(pairingMessages[locale ?? "en"]).replace(/CodeNomad
` +} diff --git a/packages/cloudflare/src/remote-control/headers.ts b/packages/cloudflare/src/remote-control/headers.ts new file mode 100644 index 000000000..bd93bdc55 --- /dev/null +++ b/packages/cloudflare/src/remote-control/headers.ts @@ -0,0 +1,51 @@ +import type { HeaderEntries } from "@codenomad/remote-control-protocol" + +const REQUEST_BLOCKLIST = new Set([ + "authorization", + "cf-connecting-ip", + "cf-ipcountry", + "cf-ray", + "cf-visitor", + "connection", + "cookie", + "host", + "origin", + "proxy-authorization", + "proxy-connection", + "sec-websocket-extensions", + "sec-websocket-key", + "sec-websocket-version", + "transfer-encoding", + "upgrade", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-codenomad-relay-device-id", + "x-codenomad-relay-operation", +]) + +const RESPONSE_BLOCKLIST = new Set([ + "connection", + "content-encoding", + "content-length", + "set-cookie", + "transfer-encoding", + "upgrade", +]) + +export function relayRequestHeaders(headers: Headers): HeaderEntries { + const result: HeaderEntries = [] + headers.forEach((value, name) => { + if (!REQUEST_BLOCKLIST.has(name.toLowerCase())) result.push([name, value]) + }) + return result +} + +export function relayResponseHeaders(entries: HeaderEntries): Headers { + const headers = new Headers() + for (const [name, value] of entries) { + if (!RESPONSE_BLOCKLIST.has(name.toLowerCase())) headers.append(name, value) + } + headers.set("Cache-Control", headers.get("Cache-Control") ?? "no-store") + return headers +} diff --git a/packages/cloudflare/src/remote-control/host-object.ts b/packages/cloudflare/src/remote-control/host-object.ts new file mode 100644 index 000000000..8153a2f34 --- /dev/null +++ b/packages/cloudflare/src/remote-control/host-object.ts @@ -0,0 +1,463 @@ +import { + REMOTE_CONTROL_PROTOCOL_VERSION, + decodeBase64, + encodeBase64, + type HostToRelayMessage, + type RelayToHostMessage, +} from "@codenomad/remote-control-protocol" +import { relayRequestHeaders, relayResponseHeaders } from "./headers" +import { bearerToken, clearDeviceCookie, cookieToken, deviceCookie, randomToken, tokenHash } from "./security" + +const PAIRING_TTL_MS = 10 * 60_000 +const DEVICE_TTL_MS = 30 * 24 * 60 * 60_000 +const HOST_SECRET_KEY = "host-secret" +const PAIRING_PREFIX = "pair:" +const DEVICE_PREFIX = "device:" +const SOCKET_HANDSHAKE_TIMEOUT_MS = 15_000 +const HTTP_RESPONSE_TIMEOUT_MS = 30_000 +const MAX_QUEUED_SOCKET_MESSAGES = 256 +const MAX_HTTP_REQUEST_BODY_BYTES = 20 * 1024 * 1024 + +interface PairingRecord { + expiresAt: number + connectionId: string +} + +interface DeviceRecord { + id: string + name: string + createdAt: number + lastSeenAt: number + expiresAt: number +} + +interface PendingHttp { + resolve: (value: { status: number; headers: Headers; stream: ReadableStream }) => void + reject: (reason: Error) => void + controller?: ReadableStreamDefaultController + queued: Uint8Array[] + ended: boolean + deviceId: string + timeout: ReturnType +} + +interface PendingSocket { + client: WebSocket + ready: boolean + queued: Array<{ data: string; binary: boolean }> + resolveReady: (protocol?: string) => void + rejectReady: (error: Error) => void + deviceId: string +} + +export class RemoteControlHost implements DurableObject { + private hostSocket: WebSocket | null = null + private hostConnectionId: string | null = null + private hostReady = false + private readonly pendingHttp = new Map() + private readonly pendingSockets = new Map() + + constructor(private readonly state: DurableObjectState) {} + + async fetch(request: Request): Promise { + const operation = request.headers.get("x-codenomad-relay-operation") + + if (operation === "host-connect") return this.connectHost(request) + if (operation === "pair-create") return this.createPairing(request) + if (operation === "pair-exchange") return this.exchangePairing(request) + if (operation === "devices") return this.devices(request) + if (operation === "device-revoke") return this.revokeDevice(request) + if (operation === "proxy") return this.proxy(request) + return Response.json({ error: "Unknown remote-control operation" }, { status: 404 }) + } + + private async connectHost(request: Request): Promise { + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return Response.json({ error: "WebSocket required" }, { status: 426 }) + } + if (!(await this.authorizeHost(request, true))) return Response.json({ error: "Unauthorized" }, { status: 401 }) + + const pair = new WebSocketPair() + const client = pair[0] + const server = pair[1] + server.accept() + + const previous = this.hostSocket + if (previous) this.failPending("CodeNomad host reconnected") + this.hostSocket = server + this.hostConnectionId = crypto.randomUUID() + this.hostReady = false + previous?.close(1012, "Host reconnected") + server.addEventListener("message", (event) => this.onHostMessage(server, event)) + server.addEventListener("close", () => this.onHostClosed(server)) + server.addEventListener("error", () => this.onHostClosed(server)) + + return new Response(null, { status: 101, webSocket: client }) + } + + private async createPairing(request: Request): Promise { + if (request.method !== "POST") return new Response(null, { status: 405 }) + if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) + if (!this.isHostConnected()) return Response.json({ error: "Host is offline" }, { status: 409 }) + + const token = randomToken() + const expiresAt = Date.now() + PAIRING_TTL_MS + await this.state.storage.put(`${PAIRING_PREFIX}${await tokenHash(token)}`, { + expiresAt, + connectionId: this.hostConnectionId!, + } satisfies PairingRecord) + return Response.json({ token, expiresAt: new Date(expiresAt).toISOString() }) + } + + private async exchangePairing(request: Request): Promise { + if (request.method !== "POST") return new Response(null, { status: 405 }) + const input: { token?: unknown; name?: unknown } = await request + .json<{ token?: unknown; name?: unknown }>() + .catch(() => ({})) + const token = typeof input.token === "string" ? input.token.trim() : "" + if (!token) return Response.json({ error: "Pairing token required" }, { status: 400 }) + + const key = `${PAIRING_PREFIX}${await tokenHash(token)}` + const pairing = await this.state.storage.transaction(async (transaction) => { + const record = await transaction.get(key) + if (record) await transaction.delete(key) + return record + }) + if (!pairing || pairing.expiresAt <= Date.now() || !this.isHostConnected() || pairing.connectionId !== this.hostConnectionId) { + return Response.json({ error: "Pairing link is invalid or expired" }, { status: 401 }) + } + + const deviceToken = randomToken() + const now = Date.now() + const device: DeviceRecord = { + id: crypto.randomUUID(), + name: typeof input.name === "string" && input.name.trim() ? input.name.trim().slice(0, 80) : "Remote device", + createdAt: now, + lastSeenAt: now, + expiresAt: now + DEVICE_TTL_MS, + } + await this.state.storage.put(`${DEVICE_PREFIX}${await tokenHash(deviceToken)}`, device) + return new Response(null, { + status: 204, + headers: { "Set-Cookie": deviceCookie(deviceToken, Math.floor(DEVICE_TTL_MS / 1000)) }, + }) + } + + private async devices(request: Request): Promise { + if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) + const records = await this.state.storage.list({ prefix: DEVICE_PREFIX }) + const now = Date.now() + const expired = Array.from(records.entries()).filter(([, device]) => device.expiresAt <= now).map(([key]) => key) + if (expired.length) await this.state.storage.delete(expired) + const devices = Array.from(records.values()) + .filter((device) => device.expiresAt > now) + .map((device) => ({ + id: device.id, + name: device.name, + createdAt: new Date(device.createdAt).toISOString(), + lastSeenAt: new Date(device.lastSeenAt).toISOString(), + })) + return Response.json({ devices }) + } + + private async revokeDevice(request: Request): Promise { + if (request.method !== "DELETE") return new Response(null, { status: 405 }) + if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) + const deviceId = request.headers.get("x-codenomad-relay-device-id") + const records = await this.state.storage.list({ prefix: DEVICE_PREFIX }) + const entry = Array.from(records.entries()).find(([, device]) => device.id === deviceId) + if (entry) { + await this.state.storage.delete(entry[0]) + for (const [id, pending] of this.pendingHttp) { + if (pending.deviceId !== deviceId) continue + this.sendHost({ type: "http.cancel", id }) + this.failHttp(id, new Error("Remote device was revoked")) + } + for (const [id, pending] of this.pendingSockets) { + if (pending.deviceId === deviceId) this.closeSocket(id, "Remote device was revoked", 1008) + } + } + return new Response(null, { status: 204 }) + } + + private async proxy(request: Request): Promise { + const device = await this.authorizeDevice(request) + if (!device) { + return Response.json({ error: "Remote device is not paired" }, { + status: 401, + headers: { "Set-Cookie": clearDeviceCookie() }, + }) + } + if (!this.isHostConnected()) return Response.json({ error: "CodeNomad host is offline" }, { status: 503 }) + if (request.headers.get("upgrade")?.toLowerCase() === "websocket") return this.proxySocket(request, device.id) + return this.proxyHttp(request, device.id) + } + + private async proxyHttp(request: Request, deviceId: string): Promise { + const id = crypto.randomUUID() + let body: string | undefined + if (request.method !== "GET" && request.method !== "HEAD") { + const bytes = new Uint8Array(await request.arrayBuffer()) + if (bytes.byteLength > MAX_HTTP_REQUEST_BODY_BYTES) { + return Response.json({ error: "Remote request body is too large" }, { status: 413 }) + } + body = encodeBase64(bytes) + } + const message: RelayToHostMessage = { + type: "http.request", + id, + method: request.method, + path: remotePath(request), + headers: relayRequestHeaders(request.headers), + ...(body ? { body } : {}), + } + + const response = new Promise<{ status: number; headers: Headers; stream: ReadableStream }>((resolve, reject) => { + const timeout = setTimeout(() => { + this.sendHost({ type: "http.cancel", id }) + this.failHttp(id, new Error("CodeNomad host response timed out")) + }, HTTP_RESPONSE_TIMEOUT_MS) + this.pendingHttp.set(id, { resolve, reject, queued: [], ended: false, deviceId, timeout }) + }) + request.signal.addEventListener("abort", () => { + this.sendHost({ type: "http.cancel", id }) + this.failHttp(id, new Error("Remote request cancelled")) + }, { once: true }) + if (!this.sendHost(message)) this.failHttp(id, new Error("CodeNomad host disconnected")) + + try { + const result = await response + const body = request.method === "HEAD" || responseMustNotHaveBody(result.status) ? null : result.stream + return new Response(body, { + status: result.status, + headers: result.headers, + }) + } catch (error) { + return Response.json({ error: error instanceof Error ? error.message : "Remote request failed" }, { status: 502 }) + } + } + + private async proxySocket(request: Request, deviceId: string): Promise { + const id = crypto.randomUUID() + const pair = new WebSocketPair() + const client = pair[0] + const server = pair[1] + server.accept() + const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((value) => value.trim()).filter(Boolean) + let resolveReady!: (protocol?: string) => void + let rejectReady!: (error: Error) => void + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + this.pendingSockets.set(id, { client: server, ready: false, queued: [], resolveReady, rejectReady, deviceId }) + server.addEventListener("message", (event) => { + const binary = typeof event.data !== "string" + const bytes = binary ? new Uint8Array(event.data as ArrayBuffer) : new TextEncoder().encode(event.data as string) + this.sendHost({ type: "socket.message", id, data: encodeBase64(bytes), binary }) + }) + server.addEventListener("close", (event) => { + this.sendHost({ type: "socket.close", id, code: event.code, reason: event.reason }) + const pending = this.pendingSockets.get(id) + if (pending && !pending.ready) pending.rejectReady(new Error("Remote WebSocket client disconnected")) + this.pendingSockets.delete(id) + }) + server.addEventListener("error", () => { + this.sendHost({ type: "socket.close", id, code: 1011, reason: "Client socket failed" }) + const pending = this.pendingSockets.get(id) + if (pending && !pending.ready) pending.rejectReady(new Error("Remote WebSocket client failed")) + this.pendingSockets.delete(id) + }) + const sent = this.sendHost({ + type: "socket.open", + id, + path: remotePath(request), + headers: relayRequestHeaders(request.headers), + protocols, + }) + if (!sent) rejectReady(new Error("CodeNomad host disconnected")) + const timeout = setTimeout(() => rejectReady(new Error("CodeNomad WebSocket handshake timed out")), SOCKET_HANDSHAKE_TIMEOUT_MS) + try { + const protocol = await ready + const headers = protocol ? { "Sec-WebSocket-Protocol": protocol } : undefined + return new Response(null, { status: 101, webSocket: client, headers }) + } catch (error) { + this.pendingSockets.delete(id) + server.close(1013, "CodeNomad host unavailable") + this.sendHost({ type: "socket.close", id, code: 1013, reason: "Remote handshake cancelled" }) + return Response.json({ error: error instanceof Error ? error.message : "WebSocket handshake failed" }, { status: 502 }) + } finally { + clearTimeout(timeout) + } + } + + private onHostMessage(socket: WebSocket, event: MessageEvent) { + if (this.hostSocket !== socket) return + if (typeof event.data !== "string") return + let message: HostToRelayMessage + try { + message = JSON.parse(event.data) as HostToRelayMessage + } catch { + this.hostSocket?.close(1003, "Invalid relay message") + return + } + + if (message.type === "ready") { + if (message.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) { + this.hostSocket?.close(1002, "Unsupported protocol") + return + } + this.hostReady = true + this.sendHost({ type: "ready", protocol: REMOTE_CONTROL_PROTOCOL_VERSION }) + return + } + if (message.type === "http.start") return this.startHttp(message) + if (message.type === "http.chunk") return this.chunkHttp(message.id, decodeBase64(message.data)) + if (message.type === "http.end") return this.endHttp(message.id) + if (message.type === "http.error") return this.failHttp(message.id, new Error(message.message)) + if (message.type === "socket.ready") return this.readySocket(message.id, message.protocol) + if (message.type === "socket.message") return this.messageSocket(message.id, message.data, message.binary) + if (message.type === "socket.close") return this.closeSocket(message.id, message.reason) + if (message.type === "socket.error") return this.closeSocket(message.id, message.message) + } + + private startHttp(message: Extract) { + const pending = this.pendingHttp.get(message.id) + if (!pending) return + clearTimeout(pending.timeout) + const stream = new ReadableStream({ + start: (controller) => { + pending.controller = controller + for (const chunk of pending.queued) controller.enqueue(chunk) + pending.queued.length = 0 + if (pending.ended) controller.close() + }, + cancel: () => { + this.sendHost({ type: "http.cancel", id: message.id }) + this.pendingHttp.delete(message.id) + }, + }) + pending.resolve({ status: message.status, headers: relayResponseHeaders(message.headers), stream }) + } + + private chunkHttp(id: string, chunk: Uint8Array) { + const pending = this.pendingHttp.get(id) + if (!pending || pending.ended) return + if (pending.controller) pending.controller.enqueue(chunk) + else pending.queued.push(chunk) + } + + private endHttp(id: string) { + const pending = this.pendingHttp.get(id) + if (!pending) return + clearTimeout(pending.timeout) + pending.ended = true + pending.controller?.close() + this.pendingHttp.delete(id) + } + + private failHttp(id: string, error: Error) { + const pending = this.pendingHttp.get(id) + if (!pending) return + clearTimeout(pending.timeout) + pending.reject(error) + pending.controller?.error(error) + this.pendingHttp.delete(id) + } + + private readySocket(id: string, protocol?: string) { + const pending = this.pendingSockets.get(id) + if (!pending) return + pending.ready = true + pending.resolveReady(protocol) + for (const entry of pending.queued) this.sendSocket(pending.client, entry.data, entry.binary) + pending.queued.length = 0 + } + + private messageSocket(id: string, data: string, binary: boolean) { + const pending = this.pendingSockets.get(id) + if (!pending) return + if (!pending.ready && pending.queued.length >= MAX_QUEUED_SOCKET_MESSAGES) { + this.closeSocket(id, "Too many queued CodeNomad messages", 1009) + } else if (!pending.ready) pending.queued.push({ data, binary }) + else this.sendSocket(pending.client, data, binary) + } + + private sendSocket(socket: WebSocket, data: string, binary: boolean) { + const bytes = decodeBase64(data) + socket.send(binary ? bytes.buffer : new TextDecoder().decode(bytes)) + } + + private closeSocket(id: string, reason?: string, code = 1011) { + const pending = this.pendingSockets.get(id) + if (!pending) return + if (!pending.ready) pending.rejectReady(new Error(reason || "CodeNomad WebSocket handshake failed")) + pending.client.close(code, reason?.slice(0, 120) || "Host socket closed") + this.pendingSockets.delete(id) + } + + private onHostClosed(socket: WebSocket) { + if (this.hostSocket !== socket) return + this.hostSocket = null + this.hostConnectionId = null + this.hostReady = false + this.failPending("CodeNomad host disconnected") + } + + private failPending(reason: string) { + for (const id of this.pendingHttp.keys()) this.failHttp(id, new Error(reason)) + for (const id of this.pendingSockets.keys()) this.closeSocket(id, reason) + } + + private isHostConnected(): boolean { + return this.hostReady && this.hostSocket?.readyState === WebSocket.OPEN + } + + private sendHost(message: RelayToHostMessage): boolean { + if (!this.isHostConnected()) return false + try { + this.hostSocket!.send(JSON.stringify(message)) + return true + } catch { + return false + } + } + + private async authorizeHost(request: Request, allowRegistration = false): Promise { + const token = bearerToken(request) + if (!token) return false + const presented = await tokenHash(token) + if (!allowRegistration) return await this.state.storage.get(HOST_SECRET_KEY) === presented + return this.state.storage.transaction(async (transaction) => { + const stored = await transaction.get(HOST_SECRET_KEY) + if (stored) return stored === presented + await transaction.put(HOST_SECRET_KEY, presented) + return true + }) + } + + private async authorizeDevice(request: Request): Promise { + const token = cookieToken(request) + if (!token) return null + const key = `${DEVICE_PREFIX}${await tokenHash(token)}` + const device = await this.state.storage.get(key) + if (!device || device.expiresAt <= Date.now()) { + await this.state.storage.delete(key) + return null + } + if (Date.now() - device.lastSeenAt > 60_000) { + device.lastSeenAt = Date.now() + await this.state.storage.put(key, device) + } + return device + } +} + +function remotePath(request: Request): string { + const url = new URL(request.url) + return `${url.pathname}${url.search}` +} + +function responseMustNotHaveBody(status: number): boolean { + return status === 204 || status === 205 || status === 304 +} diff --git a/packages/cloudflare/src/remote-control/security.test.ts b/packages/cloudflare/src/remote-control/security.test.ts new file mode 100644 index 000000000..0d87f02ff --- /dev/null +++ b/packages/cloudflare/src/remote-control/security.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { DEVICE_COOKIE, bearerToken, clearDeviceCookie, cookieToken, deviceCookie, tokenHash } from "./security" + +test("host bearer tokens are read only from the authorization header", () => { + assert.equal(bearerToken(new Request("https://relay.example/?secret=query", { headers: { Authorization: "Bearer host-secret" } })), "host-secret") + assert.equal(bearerToken(new Request("https://relay.example/?secret=query")), null) +}) + +test("device credentials use secure host-scoped cookies", () => { + const cookie = deviceCookie("device token", 60) + assert.match(cookie, new RegExp(`^${DEVICE_COOKIE}=`)) + assert.match(cookie, /HttpOnly; Secure; SameSite=Strict; Path=\/; Max-Age=60/) + assert.equal(cookieToken(new Request("https://host.relay.example", { headers: { Cookie: cookie } })), "device token") + assert.match(clearDeviceCookie(), /Max-Age=0/) +}) + +test("stored credentials are deterministic hashes rather than raw tokens", async () => { + const hash = await tokenHash("secret") + assert.match(hash, /^[a-f0-9]{64}$/) + assert.equal(hash, await tokenHash("secret")) + assert.notEqual(hash, "secret") +}) diff --git a/packages/cloudflare/src/remote-control/security.ts b/packages/cloudflare/src/remote-control/security.ts new file mode 100644 index 000000000..2f3aaf99c --- /dev/null +++ b/packages/cloudflare/src/remote-control/security.ts @@ -0,0 +1,39 @@ +const encoder = new TextEncoder() + +export const HOST_ID_PATTERN = /^[a-f0-9]{32}$/ +export const DEVICE_COOKIE = "codenomad_remote_device" + +export function randomToken(byteLength = 32): string { + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)) + let binary = "" + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +export async function tokenHash(token: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(token)) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} + +export function bearerToken(request: Request): string | null { + const value = request.headers.get("authorization") ?? "" + const match = value.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() || null +} + +export function cookieToken(request: Request): string | null { + const cookies = request.headers.get("cookie") ?? "" + for (const entry of cookies.split(";")) { + const [name, ...parts] = entry.trim().split("=") + if (name === DEVICE_COOKIE) return decodeURIComponent(parts.join("=")) + } + return null +} + +export function deviceCookie(token: string, maxAgeSeconds: number): string { + return `${DEVICE_COOKIE}=${encodeURIComponent(token)}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${maxAgeSeconds}` +} + +export function clearDeviceCookie(): string { + return `${DEVICE_COOKIE}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0` +} diff --git a/packages/cloudflare/tsconfig.json b/packages/cloudflare/tsconfig.json new file mode 100644 index 000000000..1a304045e --- /dev/null +++ b/packages/cloudflare/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "WebWorker"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/cloudflare/wrangler.toml b/packages/cloudflare/wrangler.toml index e9e6e3b08..83be69378 100644 --- a/packages/cloudflare/wrangler.toml +++ b/packages/cloudflare/wrangler.toml @@ -8,6 +8,25 @@ compatibility_date = "2026-01-22" pattern = "ui.codenomad.neuralnomads.ai" custom_domain = true +[[routes]] +pattern = "remote.codenomad.neuralnomads.ai" +custom_domain = true + +[[routes]] +pattern = "*.remote.codenomad.neuralnomads.ai/*" +zone_name = "neuralnomads.ai" + +[vars] +REMOTE_BASE_HOST = "remote.codenomad.neuralnomads.ai" + +[[durable_objects.bindings]] +name = "REMOTE_HOSTS" +class_name = "RemoteControlHost" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["RemoteControlHost"] + [assets] directory = "./dist" binding = "ASSETS" diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index e6d3b955f..771123e17 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -5,7 +5,6 @@ import type { DeveloperMode } from "./developer-mode" import type { CliProcessManager } from "./process-manager" import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open" import { popupTitlebarMenu, setWorkspaceMenuEnabled, type TitlebarMenu } from "./menu" -import { requireHttpUrl } from "./navigation-security" import { validateMainFrame } from "./ipc-security" interface LocalSender { @@ -17,7 +16,6 @@ interface CliIPCDependencies { resolveLocal(sender: IpcMainInvokeEvent["sender"]): LocalSender | undefined resolvePreferences?(sender: IpcMainInvokeEvent["sender"]): BrowserWindow | undefined getAllowedOrigins(window: BrowserWindow): string[] - openRemoteWindow(payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }): Promise newWindow(): Promise nextFolder(windowId: string): string | null acknowledgeFolder(windowId: string, folder: string, opened: boolean): void @@ -179,19 +177,6 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD anyTrusted(event) return { granted: await requestMicrophoneAccess() } }) - ipcMain.handle("remote:openWindow", async (event, payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }) => { - settings(event) - if (!payload || typeof payload.id !== "string" || !payload.id.trim() || typeof payload.name !== "string" || typeof payload.baseUrl !== "string" - || (payload.entryUrl !== undefined && typeof payload.entryUrl !== "string") - || (payload.proxySessionId !== undefined && typeof payload.proxySessionId !== "string") - || typeof payload.skipTlsVerify !== "boolean") { - throw new Error("Invalid remote window request") - } - requireHttpUrl(payload.baseUrl, "baseUrl") - if (payload.entryUrl !== undefined) requireHttpUrl(payload.entryUrl, "entryUrl") - await dependencies.openRemoteWindow(payload) - return { ok: true } - }) ipcMain.handle("notifications:show", async (event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { anyTrusted(event) if (!Notification.isSupported()) return { ok: false, reason: "unsupported" } diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 278d199fa..cafd95cfe 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -15,15 +15,15 @@ import { LocalWindowRegistry, type LocalWindowRecord } from "./local-window-regi import { clearWorkspaceMenuWindow, createApplicationMenu, setWorkspaceMenuEnabled } from "./menu" import { resolveFocusedLocalTarget, resolveWindowTarget } from "./menu-target" import { MultiwindowLifecycle } from "./multiwindow-lifecycle" -import { decideNavigation, requireHttpUrl } from "./navigation-security" +import { decideNavigation } from "./navigation-security" import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions" import { setupPreferencesIPC } from "./preferences-ipc" import { createPreferencesUrl, PreferencesWindowRegistry, type PreferencesRequest } from "./preferences-window" import { CliProcessManager } from "./process-manager" -import { navigateRemoteWindow, RemoteWindowRegistry } from "./remote-window-registry" +import { navigateTrustedWindow } from "./window-navigation" import { resolveConfiguredRendererOrigins } from "./renderer-origin" import { SerializedLifecycle } from "./serialized-lifecycle" -import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, prepareSecondLaunchIntent, resolveRemoteSessionPartition, resolveStorageScope, startPrimaryInstance, type LaunchIntent } from "./startup" +import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, parseLaunchIntent, prepareSecondLaunchIntent, resolveStorageScope, startPrimaryInstance, type LaunchIntent } from "./startup" import { clampWindowBounds, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, installWindowZoomInput, restoreWindowState, WindowStateTracker } from "./window-state" const mainDirname = dirname(fileURLToPath(import.meta.url)) @@ -111,18 +111,10 @@ function runPrimary(firstIntent: LaunchIntent) { requestRelaunch: () => lifecycle.requestRelaunch(), }) const cli = new CliProcessManager((method) => developerMode.handleNativeRequest(method)) - const remoteOrigins = new Map>() - const insecureOrigins = new Map>() + const windowOrigins = new Map>() const navigationLifecycle = new SerializedLifecycle() let backendUrl: string | null = null let backendTargetUrl: string | null = null - const remoteWindows = new RemoteWindowRegistry((sessionId) => { - if (!backendUrl) return - const target = new URL(`/api/remote-proxy/sessions/${encodeURIComponent(sessionId)}`, backendUrl) - const request = (target.protocol === "https:" ? https : http).request(target, { method: "DELETE" }, (response) => response.resume()) - request.on("error", (error) => console.warn("[electron] failed to clean up remote proxy session", sessionId, error)) - request.end() - }) const preferencesWindows = new PreferencesWindowRegistry() let pendingPreferencesRestore = clientState.preferences let preferencesNavigation: ClientStateNavigationController | null = null @@ -130,7 +122,7 @@ function runPrimary(firstIntent: LaunchIntent) { let preferencesTransitionId = 0 const getAllowedOrigins = (window?: BrowserWindow | null): string[] => { - const origins = new Set(remoteOrigins.get(window?.id ?? -1) ?? []) + const origins = new Set(windowOrigins.get(window?.id ?? -1) ?? []) for (const origin of resolveConfiguredRendererOrigins(backendUrl, app.isPackaged, [process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL])) origins.add(origin) return [...origins] } @@ -140,6 +132,7 @@ function runPrimary(firstIntent: LaunchIntent) { isSupportWindow: (window) => preferencesWindows.current() === window, removeWindowState: (id) => clientState.removeWindow(id), getAllowedRendererOrigins: getAllowedOrigins, isTrustedRendererOrigin: isAllowedRendererOrigin, + shouldKeepBackendAlive: () => isRemoteControlEnabled(backendUrl, cli), navigationLifecycle, }) const bindClientState = setupClientStateIPC(ipcMain, clientState, (sender) => registry.resolve(sender), getAllowedOrigins) @@ -165,7 +158,7 @@ function runPrimary(firstIntent: LaunchIntent) { await (target.url ? window.loadURL(target.url) : window.loadFile(target.file!)) if (!record.navigation.isCurrent(generation)) return record.backendUrl = null - remoteOrigins.delete(record.window.id) + windowOrigins.delete(record.window.id) }).catch((error) => { if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load loading screen", error) }) @@ -176,18 +169,18 @@ function runPrimary(firstIntent: LaunchIntent) { try { origin = new URL(url).origin } catch { return } await record.navigation.navigate(async (window, generation) => { if (!record.navigation.isCurrent(generation)) return - const previous = remoteOrigins.get(record.window.id) - remoteOrigins.set(record.window.id, new Set([...(previous ?? []), origin])) + const previous = windowOrigins.get(record.window.id) + windowOrigins.set(record.window.id, new Set([...(previous ?? []), origin])) try { await window.loadURL(url) } catch (error) { if (record.navigation.isCurrent(generation)) { - if (previous) remoteOrigins.set(record.window.id, previous); else remoteOrigins.delete(record.window.id) + if (previous) windowOrigins.set(record.window.id, previous); else windowOrigins.delete(record.window.id) } throw error } if (!record.navigation.isCurrent(generation)) return record.loading = false record.backendUrl = url - remoteOrigins.set(record.window.id, new Set([origin])) + windowOrigins.set(record.window.id, new Set([origin])) }).catch((error) => { if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load backend", error) }) @@ -248,8 +241,7 @@ function runPrimary(firstIntent: LaunchIntent) { window.on("closed", () => { registry.remove(windowId) clearWorkspaceMenuWindow(webContentsId) - remoteOrigins.delete(nativeWindowId) - insecureOrigins.delete(webContentsId) + windowOrigins.delete(nativeWindowId) }) if (isMac) window.webContents.session.setSpellCheckerEnabled(false) if (process.env.NODE_ENV === "development") window.webContents.openDevTools({ mode: "detach" }) @@ -283,7 +275,7 @@ function runPrimary(firstIntent: LaunchIntent) { setupCliIPC(cli, { resolveLocal: (sender) => registry.resolve(sender), resolvePreferences: (sender) => preferencesWindows.resolve(sender), getAllowedOrigins, - openRemoteWindow, newWindow: () => intentQueue.enqueue({ newWindow: true, folders: [] }), + newWindow: () => intentQueue.enqueue({ newWindow: true, folders: [] }), nextFolder: (id) => registry.nextFolder(id), acknowledgeFolder: (id, folder, opened) => registry.acknowledgeFolder(id, folder, opened), developerMode, }) @@ -322,12 +314,6 @@ function runPrimary(firstIntent: LaunchIntent) { const intent = parseLaunchIntent([path], process.cwd()) if (intent.folders.length) void intentQueue.enqueue(intent).catch(() => {}) }) - app.on("certificate-error", (event, contents, url, error, _certificate, callback) => { - if (contents && isRemoteCertificateAllowed(contents.id, url, insecureOrigins)) { - event.preventDefault(); console.warn("[cli] allowing insecure remote certificate", url, error); callback(true) - } else callback(false) - }) - cli.on("bootstrapToken", (token) => bootstrap.setToken(token)) cli.on("ready", (status) => { if (!status.url) return @@ -386,43 +372,6 @@ function runPrimary(firstIntent: LaunchIntent) { const candidates = [join(process.resourcesPath, "preload/index.js"), join(mainDirname, "../preload/index.js"), join(mainDirname, "../preload/index.cjs"), join(mainDirname, "../../electron/preload/index.cjs"), join(app.getAppPath(), "electron/preload/index.cjs")] return candidates.find(existsSync) ?? candidates[0] } - async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }) { - return remoteWindows.serialize(payload.id, async () => { - const base = requireHttpUrl(payload.baseUrl, "baseUrl") - const target = requireHttpUrl(payload.entryUrl ?? payload.baseUrl, "entryUrl") - const title = `${payload.name} - ${payload.baseUrl}` - const existing = remoteWindows.reuse(payload.id, payload.proxySessionId) - if (existing) { - const allowedOrigins = new Set([base.origin, target.origin]) - existing.setTitle(title) - await navigateRemoteWindow(existing, target, allowedOrigins, remoteOrigins, insecureOrigins, payload.skipTlsVerify) - return - } - const remoteSession = session.fromPartition(resolveRemoteSessionPartition(payload.id, payload.proxySessionId)) - const window = new BrowserWindow({ - width: 1400, height: 900, minWidth: 800, minHeight: 600, backgroundColor: "#1a1a1a", icon: getIconPath(), title, - webPreferences: { session: remoteSession, preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, additionalArguments: ["--codenomad-window-context=remote"] }, - }) - const nativeWindowId = window.id - const webContentsId = window.webContents.id - const allowedOrigins = new Set([base.origin, target.origin]) - remoteWindows.register(payload.id, window, payload.proxySessionId) - if (isMac) configureMediaPermissionHandlers(() => BrowserWindow.getAllWindows() - .filter((candidate) => candidate.webContents.session === remoteSession) - .flatMap((candidate) => [...(remoteOrigins.get(candidate.id) ?? [])]), remoteSession) - window.setTitle(title) - window.webContents.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle(title) }) - setupNavigationGuards(window, undefined, getAllowedOrigins, getLoadingUrl) - lifecycle.attachRemote(window) - window.on("closed", () => { remoteOrigins.delete(nativeWindowId); insecureOrigins.delete(webContentsId) }) - try { await navigateRemoteWindow(window, target, allowedOrigins, remoteOrigins, insecureOrigins, payload.skipTlsVerify) } catch (error) { - console.warn("[electron] failed to load remote window; showing loading screen", error) - const loading = loadingTarget() - await (loading.url ? window.loadURL(loading.url) : window.loadFile(loading.file!)) - } - }) - } - async function openPreferences(request: PreferencesRequest): Promise { if (preferencesWindows.reuse(request)) { await clientState.setPreferences(request) @@ -438,7 +387,6 @@ function runPrimary(firstIntent: LaunchIntent) { }, }) const nativeWindowId = window.id - const webContentsId = window.webContents.id if (!isMac) window.setMenuBarVisibility(false) preferencesWindows.register(window, request) preferencesNavigation = new ClientStateNavigationController(window, { @@ -448,11 +396,10 @@ function runPrimary(firstIntent: LaunchIntent) { lifecycle: navigationLifecycle, }) setupNavigationGuards(window, preferencesNavigation, getAllowedOrigins, getLoadingUrl) - lifecycle.attachRemote(window) + lifecycle.attachSupportWindow(window) window.webContents.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle("Preferences") }) window.on("closed", () => { - remoteOrigins.delete(nativeWindowId) - insecureOrigins.delete(webContentsId) + windowOrigins.delete(nativeWindowId) preferencesNavigation = null preferencesTransition = undefined if (!lifecycle.isExitAllowed()) { @@ -480,7 +427,7 @@ function runPrimary(firstIntent: LaunchIntent) { const target = createPreferencesUrl(url, request.section) await navigation.navigate(async (current, generation) => { if (!navigation.isCurrent(generation)) return - await navigateRemoteWindow(current, target, new Set([target.origin]), remoteOrigins, insecureOrigins, false) + await navigateTrustedWindow(current, target, new Set([target.origin]), windowOrigins) }).catch(async (error) => { if (!isIgnorableNavigationError(error)) console.warn("[electron] failed to load Preferences; showing loading screen", error) await loadPreferencesLoadingNow(window) @@ -510,7 +457,7 @@ function runPrimary(firstIntent: LaunchIntent) { await navigation.navigate(async (current, generation) => { if (!navigation.isCurrent(generation)) return await (target.url ? current.loadURL(target.url) : current.loadFile(target.file!)) - if (navigation.isCurrent(generation)) remoteOrigins.delete(current.id) + if (navigation.isCurrent(generation)) windowOrigins.delete(current.id) }).catch((error) => { preferencesWindows.cancelNavigation(window) if (!isIgnorableNavigationError(error)) console.error("[electron] failed to load Preferences loading screen", error) @@ -565,4 +512,33 @@ async function exchangeBootstrapToken(baseUrl: string, token: string, cli: CliPr return true } +async function isRemoteControlEnabled(baseUrl: string | null, cli: CliProcessManager): Promise { + if (!baseUrl) return false + const cookie = (await session.defaultSession.cookies.get({ url: baseUrl, name: cli.getAuthCookieName() }))[0] + if (!cookie) return false + const target = new URL("/api/remote-control/status", baseUrl) + const transport = target.protocol === "https:" ? https : http + return new Promise((resolve) => { + const request = transport.request(target, { + method: "GET", + headers: { Cookie: `${cookie.name}=${cookie.value}` }, + timeout: 2_000, + }, (response) => { + const chunks: Buffer[] = [] + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + response.on("end", () => { + try { + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { enabled?: unknown } + resolve(response.statusCode === 200 && payload.enabled === true) + } catch { + resolve(false) + } + }) + }) + request.on("timeout", () => request.destroy()) + request.on("error", () => resolve(false)) + request.end() + }) +} + if (isMac) app.commandLine.appendSwitch("disable-spell-checking") diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts index c57b14115..0c201d465 100644 --- a/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts @@ -31,7 +31,7 @@ test("closing one local window removes only its V3 record and leaves backend run assert.deepEqual(calls, ["prevent", "renderer:one", "native:one", "remove:one", "close:one"]) }) -test("closing the sole local window while a remote remains removes its V3 record", async () => { +test("closing the sole local window while a support window remains removes its V3 record", async () => { const calls: string[] = [] const local = windowRecord("local", calls) const remote = { isDestroyed: () => false } @@ -47,7 +47,7 @@ test("closing the sole local window while a remote remains removes its V3 record assert.deepEqual(calls, ["prevent", "renderer:local", "native:local", "remove:local", "close:local"]) }) -test("Preferences does not turn the final local close into a destructive window removal", () => { +test("Preferences does not turn the final local close into a destructive window removal", async () => { const calls: string[] = [] const local = windowRecord("local", calls) const preferences = { isDestroyed: () => false } @@ -60,10 +60,11 @@ test("Preferences does not turn the final local close into a destructive window }) lifecycle.attach(local) local.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() assert.deepEqual(calls, ["prevent", "quit"]) }) -test("Preferences does not keep the final remote window alive", () => { +test("Preferences does not keep the final support window alive", async () => { const calls: string[] = [] const remote = windowRecord("remote", calls) const preferences = { isDestroyed: () => false } @@ -74,11 +75,37 @@ test("Preferences does not keep the final remote window alive", () => { isSupportWindow: (window) => window === preferences, removeWindowState: async () => true, getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, }) - lifecycle.attachRemote(remote.window) + lifecycle.attachSupportWindow(remote.window) remote.events.get("close")?.({ defaultPrevented: false, preventDefault: () => calls.push("prevent") }) + await tick() assert.deepEqual(calls, ["prevent", "quit"]) }) +test("Remote Control keeps the backend alive after the last local window closes", async () => { + const calls: string[] = [] + const events = new Map() + const local = windowRecord("local", calls) + const lifecycle = new MultiwindowLifecycle({ + app: { on: (name: string, handler: Function) => events.set(name, handler), quit: () => calls.push("quit"), exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => [local], + getAllWindows: () => [local.window], + removeWindowState: async (id) => { calls.push(`remove:${id}`); return true }, + getAllowedRendererOrigins: () => ["http://localhost"], + isTrustedRendererOrigin: () => true, + shouldKeepBackendAlive: async () => true, + }) + lifecycle.attach(local) + lifecycle.registerAppEvents() + + local.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + events.get("window-all-closed")?.() + + assert.deepEqual(calls, ["prevent", "renderer:local", "native:local", "remove:local", "close:local"]) +}) + test("persisted local close waits for confirmed removal and remains retryable", async () => { const calls: string[] = [] const first = windowRecord("one", calls) @@ -176,6 +203,7 @@ test("final close retains its record and shutdown stops/releases once", async () }) lifecycle.attach(first); lifecycle.registerAppEvents() first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() assert.deepEqual(calls, ["prevent", "quit"]) events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) @@ -207,7 +235,7 @@ test("Windows query preflush leaves the app alive until session end is confirmed assert.deepEqual(calls, ["renderer:one", "native:one", "aggregate", "stop", "release", "exit"]) }) -test("remote windows receive session-end cleanup without local close semantics", async () => { +test("support windows receive session-end cleanup without local close semantics", async () => { const calls: string[] = [] const events = new Map() const remote = { on: (name: string, handler: Function) => events.set(name, handler), isDestroyed: () => false } @@ -360,7 +388,7 @@ test("final remote close stays restorable by routing through app shutdown", asyn getLocalWindows: () => [], getAllWindows: () => [remote.window], removeWindowState: async () => true, getAllowedRendererOrigins: () => [], isTrustedRendererOrigin: () => false, }) - lifecycle.attachRemote(remote.window); lifecycle.registerAppEvents() + lifecycle.attachSupportWindow(remote.window); lifecycle.registerAppEvents() remote.events.get("close")?.({ preventDefault: () => calls.push("prevent-close") }) await tick(); await tick() diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.ts index 6255a0170..20157bbe3 100644 --- a/packages/electron-app/electron/main/multiwindow-lifecycle.ts +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.ts @@ -22,6 +22,7 @@ interface Dependencies { removeWindowState(id: string): Promise getAllowedRendererOrigins(window: BrowserWindow): string[] isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean + shouldKeepBackendAlive?(): Promise rendererFlushTimeoutMs?: number sessionEndCleanupTimeoutMs?: number isWindows?: boolean @@ -36,6 +37,7 @@ export class MultiwindowLifecycle { private release: Promise | null = null private exitAllowed = false private relaunchRequested = false + private keepAliveWithoutWindows = false private readonly sessionEndWindows = new WeakSet() constructor(private readonly dependencies: Dependencies) {} @@ -50,19 +52,25 @@ export class MultiwindowLifecycle { const otherLocal = this.dependencies.getLocalWindows().some((candidate) => candidate.id !== record.id && !candidate.window.isDestroyed()) const otherWindow = this.dependencies.getAllWindows().some((candidate) => candidate !== record.window && !candidate.isDestroyed() && !this.dependencies.isSupportWindow?.(candidate)) - if (!otherLocal && !otherWindow) { - this.dependencies.app.quit() - return - } closing = true - void this.flushWindow(record).then(async () => { + void (async () => { + if (!otherLocal && !otherWindow) { + const keepAlive = await this.dependencies.shouldKeepBackendAlive?.().catch(() => false) ?? false + if (!keepAlive) { + closing = false + this.dependencies.app.quit() + return + } + this.keepAliveWithoutWindows = true + } + await this.flushWindow(record) if (record.persisted !== false && !await this.dependencies.removeWindowState(record.id)) { closing = false return } approved = true record.window.close() - }).catch((error) => { + })().catch((error) => { closing = false console.warn("[client-state] local window close failed", error) }) @@ -71,12 +79,28 @@ export class MultiwindowLifecycle { this.attachSessionEnd(record.window) } - attachRemote(window: BrowserWindow): void { + attachSupportWindow(window: BrowserWindow): void { + let approved = false + let closing = false window.on("close", (event) => { - if (event.defaultPrevented || this.exitAllowed || this.dependencies.getAllWindows().some((candidate) => candidate !== window + if (approved || event.defaultPrevented || this.exitAllowed || this.dependencies.getAllWindows().some((candidate) => candidate !== window && !candidate.isDestroyed() && !this.dependencies.isSupportWindow?.(candidate))) return event.preventDefault() - if (!this.shutdown) this.dependencies.app.quit() + if (closing || this.shutdown) return + closing = true + void (this.dependencies.shouldKeepBackendAlive?.() ?? Promise.resolve(false)).then((keepAlive) => { + if (!keepAlive) { + closing = false + this.dependencies.app.quit() + return + } + this.keepAliveWithoutWindows = true + approved = true + window.close() + }, () => { + closing = false + this.dependencies.app.quit() + }) }) this.attachSessionEnd(window) } @@ -96,6 +120,7 @@ export class MultiwindowLifecycle { this.dependencies.app.on("before-quit", (event) => { if (this.exitAllowed) return event.preventDefault() + this.keepAliveWithoutWindows = false const visibleWindows = this.dependencies.getAllWindows().filter((window) => !window.isDestroyed() && window.isVisible()) for (const window of visibleWindows) window.hide() void this.startShutdown().then(() => this.exit(), (error) => { @@ -104,7 +129,9 @@ export class MultiwindowLifecycle { console.warn("[client-state] shutdown remains pending", error) }) }) - this.dependencies.app.on("window-all-closed", () => this.dependencies.app.quit()) + this.dependencies.app.on("window-all-closed", () => { + if (!this.keepAliveWithoutWindows) this.dependencies.app.quit() + }) } requestRelaunch(): void { diff --git a/packages/electron-app/electron/main/navigation-security.test.ts b/packages/electron-app/electron/main/navigation-security.test.ts index d06964ab4..cc0120c91 100644 --- a/packages/electron-app/electron/main/navigation-security.test.ts +++ b/packages/electron-app/electron/main/navigation-security.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import test from "node:test" import { decideNavigation, requireHttpUrl } from "./navigation-security" -test("remote window URLs require HTTP or HTTPS", () => { +test("trusted renderer URLs require HTTP or HTTPS", () => { assert.equal(requireHttpUrl("http://localhost:3000/app", "baseUrl").protocol, "http:") assert.equal(requireHttpUrl("https://example.com/app", "entryUrl").protocol, "https:") for (const url of ["file:///tmp/index.html", "data:text/html,hello", "javascript:alert(1)"]) { diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 168e3fb45..c2f9dc6e3 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -2,11 +2,9 @@ import { spawn, type ChildProcess } from "child_process" import { app } from "electron" import { createRequire } from "module" import { EventEmitter } from "events" -import { existsSync, readFileSync } from "fs" -import os from "os" +import { existsSync } from "fs" import path from "path" import { fileURLToPath } from "url" -import { parse as parseYaml } from "yaml" import { ensureManagedNodeBinary } from "./managed-node" import { getProcessStartIdentityAsync } from "./client-state-process-identity" import { @@ -31,7 +29,6 @@ const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete" const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete" const SESSION_COOKIE_NAME_PREFIX = "codenomad_session" type CliState = "starting" | "ready" | "error" | "stopped" -type ListeningMode = "local" | "all" export interface CliStatus { state: CliState @@ -58,75 +55,6 @@ interface CliEntryResolution { nodeArgs?: string[] } -const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" - -function isYamlPath(filePath: string): boolean { - const lower = filePath.toLowerCase() - return lower.endsWith(".yaml") || lower.endsWith(".yml") -} - -function isJsonPath(filePath: string): boolean { - return filePath.toLowerCase().endsWith(".json") -} - -function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } { - const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH - const resolved = resolveConfigPath(target) - - if (isYamlPath(resolved)) { - const baseDir = path.dirname(resolved) - return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") } - } - - if (isJsonPath(resolved)) { - const baseDir = path.dirname(resolved) - return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved } - } - - // Treat as directory. - return { - configYamlPath: path.join(resolved, "config.yaml"), - legacyJsonPath: path.join(resolved, "config.json"), - } -} - -function resolveConfigPath(configPath?: string): string { - const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH - if (target.startsWith("~/")) { - return path.join(os.homedir(), target.slice(2)) - } - return path.resolve(target) -} - -function resolveHostForMode(mode: ListeningMode): string { - return mode === "local" ? "127.0.0.1" : "0.0.0.0" -} - -function readListeningModeFromConfig(): ListeningMode { - try { - const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG) - - let parsed: any = null - if (existsSync(configYamlPath)) { - const content = readFileSync(configYamlPath, "utf-8") - parsed = parseYaml(content) - } else if (existsSync(legacyJsonPath)) { - const content = readFileSync(legacyJsonPath, "utf-8") - parsed = JSON.parse(content) - } else { - return "local" - } - - const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode - if (mode === "local" || mode === "all") { - return mode - } - } catch (error) { - console.warn("[cli] failed to read listening mode from config", error) - } - return "local" -} - export declare interface CliProcessManager { on(event: "status", listener: (status: CliStatus) => void): this on(event: "ready", listener: (status: CliStatus) => void): this @@ -197,14 +125,12 @@ export class CliProcessManager extends EventEmitter { this.childStartIdentity = undefined this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) - const listeningMode = this.resolveListeningMode() - const host = resolveHostForMode(listeningMode) - const args = this.buildCliArgs(options, host) + const args = this.buildCliArgs(options) const cliEntry = await this.awaitStartupStep(this.resolveCliEntry(options)) if (this.lifecycle.stopped) throw new Error("CLI startup interrupted by shutdown") console.info( - `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=127.0.0.1)`, ) const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } @@ -396,10 +322,6 @@ export class CliProcessManager extends EventEmitter { }) } - private resolveListeningMode(): ListeningMode { - return readListeningModeFromConfig() - } - private handleTimeout() { const timedOutChild = this.child if (timedOutChild) { @@ -514,14 +436,13 @@ export class CliProcessManager extends EventEmitter { this.emit("status", this.status) } - private buildCliArgs(options: StartOptions, host: string): string[] { - const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"] + private buildCliArgs(options: StartOptions): string[] { + const args = ["serve", "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"] if (options.dev) { // Dev: run plain HTTP + Vite dev server proxy. args.push("--https", "false", "--http", "true") - // Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks) - // by forcing an ephemeral port in dev. + // Avoid collisions with an already-running server by forcing an ephemeral port in dev. args.push("--http-port", "0") } else { // Prod desktop: always keep loopback HTTP enabled. diff --git a/packages/electron-app/electron/main/remote-window-registry.test.ts b/packages/electron-app/electron/main/remote-window-registry.test.ts deleted file mode 100644 index dc1488c6d..000000000 --- a/packages/electron-app/electron/main/remote-window-registry.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import type { BrowserWindow } from "electron" -import { navigateRemoteWindow, RemoteWindowRegistry } from "./remote-window-registry" - -function window() { - const events = new Map void>() - const calls: string[] = [] - return { - calls, - events, - value: { - isDestroyed: () => false, - isMinimized: () => false, - restore: () => calls.push("restore"), - show: () => calls.push("show"), - focus: () => calls.push("focus"), - close: () => { calls.push("close"); events.get("close")?.() }, - destroy: () => calls.push("destroy"), - on: (name: string, callback: () => void) => events.set(name, callback), - } as unknown as BrowserWindow, - } -} - -test("remote profiles reuse one window and preserve direct profile sessions", () => { - const cleaned: string[] = [] - const registry = new RemoteWindowRegistry((id) => cleaned.push(id)) - const direct = window() - registry.register("profile", direct.value) - assert.equal(registry.reuse("profile"), direct.value) - assert.deepEqual(direct.calls, ["show", "focus"]) - direct.events.get("closed")?.() - assert.deepEqual(cleaned, []) -}) - -test("proxy replacement destroys the old window without triggering close interception", () => { - const cleaned: string[] = [] - const registry = new RemoteWindowRegistry((id) => cleaned.push(id)) - const first = window() - first.events.set("close", () => first.calls.push("quit")) - registry.register("profile", first.value, "proxy-one") - assert.equal(registry.reuse("profile", "proxy-two"), undefined) - assert.deepEqual(first.calls, ["destroy"]) - assert.deepEqual(cleaned, ["proxy-one"]) - const second = window() - registry.register("profile", second.value, "proxy-two") - first.events.get("closed")?.() - second.events.get("closed")?.() - assert.deepEqual(cleaned, ["proxy-one", "proxy-two"]) -}) - -test("reused remote navigation trusts old and next redirect origins until success", async () => { - const remote = window() - const trusted = new Map([[1, new Set(["https://old.example"])]]) - const insecure = new Map([[2, new Set(["https://old.example"])]]) - Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) - remote.value.loadURL = async () => { - assert.deepEqual([...trusted.get(1)!], ["https://old.example", "https://new.example", "https://redirect.example"]) - } - - const next = new Set(["https://new.example", "https://redirect.example"]) - await navigateRemoteWindow(remote.value, new URL("https://new.example/app"), next, trusted, insecure, false) - assert.deepEqual([...trusted.get(1)!], [...next]) - assert.equal(insecure.has(2), false) -}) - -test("failed reused remote navigation restores trusted and insecure origins", async () => { - const remote = window() - const trusted = new Map([[1, new Set(["https://old.example"])]]) - const insecure = new Map([[2, new Set(["https://old.example"])]]) - Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) - remote.value.loadURL = async () => { - assert.deepEqual([...trusted.get(1)!], ["https://old.example", "https://new.example"]) - assert.deepEqual([...insecure.get(2)!], ["https://old.example", "https://new.example"]) - throw new Error("failed") - } - - const next = new Set(["https://new.example"]) - await assert.rejects(navigateRemoteWindow(remote.value, new URL("https://new.example/app"), next, trusted, insecure, true), /failed/) - assert.deepEqual([...trusted.get(1)!], ["https://old.example"]) - assert.deepEqual([...insecure.get(2)!], ["https://old.example"]) -}) - -test("stale remote navigation failure cannot replace newer committed authority", async () => { - const remote = window() - const trusted = new Map([[1, new Set(["https://old.example"])]]) - const insecure = new Map([[2, new Set(["https://old.example"])]]) - const loads: Array<{ resolve: () => void; reject: (error: Error) => void }> = [] - Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) - remote.value.loadURL = () => new Promise((resolve, reject) => loads.push({ resolve, reject })) - - const stale = navigateRemoteWindow(remote.value, new URL("https://stale.example"), new Set(["https://stale.example"]), trusted, insecure, true) - const current = navigateRemoteWindow(remote.value, new URL("https://current.example"), new Set(["https://current.example"]), trusted, insecure, false) - loads[1]!.resolve() - await current - loads[0]!.reject(new Error("stale failed")) - await stale - - assert.deepEqual([...trusted.get(1)!], ["https://current.example"]) - assert.equal(insecure.has(2), false) -}) - -test("stale remote navigation success cannot replace authority restored by a newer failure", async () => { - const remote = window() - const trusted = new Map([[1, new Set(["https://old.example"])]]) - const insecure = new Map([[2, new Set(["https://old.example"])]]) - const loads: Array<{ resolve: () => void; reject: (error: Error) => void }> = [] - Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) - remote.value.loadURL = () => new Promise((resolve, reject) => loads.push({ resolve, reject })) - - const stale = navigateRemoteWindow(remote.value, new URL("https://stale.example"), new Set(["https://stale.example"]), trusted, insecure, false) - const current = navigateRemoteWindow(remote.value, new URL("https://current.example"), new Set(["https://current.example"]), trusted, insecure, true) - loads[1]!.reject(new Error("current failed")) - await assert.rejects(current, /current failed/) - loads[0]!.resolve() - await stale - - assert.deepEqual([...trusted.get(1)!], ["https://old.example"]) - assert.deepEqual([...insecure.get(2)!], ["https://old.example"]) -}) - -test("overlapping remote opens wait for the prior loadURL fallback for the same profile", async () => { - const registry = new RemoteWindowRegistry(() => {}) - const calls: string[] = [] - let releaseFallback!: () => void - const fallback = new Promise((resolve) => { releaseFallback = resolve }) - const first = registry.serialize("profile", async () => { - calls.push("first-loadURL") - try { throw new Error("load failed") } catch { calls.push("first-fallback"); await fallback } - calls.push("first-done") - }) - const second = registry.serialize("profile", async () => { calls.push("second-loadURL") }) - - await new Promise((resolve) => setImmediate(resolve)) - assert.deepEqual(calls, ["first-loadURL", "first-fallback"]) - releaseFallback() - await Promise.all([first, second]) - assert.deepEqual(calls, ["first-loadURL", "first-fallback", "first-done", "second-loadURL"]) -}) diff --git a/packages/electron-app/electron/main/remote-window-registry.ts b/packages/electron-app/electron/main/remote-window-registry.ts deleted file mode 100644 index 1b129d292..000000000 --- a/packages/electron-app/electron/main/remote-window-registry.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { BrowserWindow } from "electron" - -interface RemoteWindowRecord { - window: BrowserWindow - proxySessionId?: string -} - -export class RemoteWindowRegistry { - private readonly records = new Map() - private readonly operations = new Map>() - - constructor(private readonly cleanupProxySession: (sessionId: string) => void) {} - - serialize(profileId: string, operation: () => Promise): Promise { - const previous = this.operations.get(profileId) ?? Promise.resolve() - const result = previous.catch(() => {}).then(operation) - const tail = result.then(() => {}, () => {}) - this.operations.set(profileId, tail) - void tail.then(() => { - if (this.operations.get(profileId) === tail) this.operations.delete(profileId) - }) - return result - } - - reuse(profileId: string, proxySessionId?: string): BrowserWindow | undefined { - const record = this.records.get(profileId) - if (!record || record.window.isDestroyed()) return undefined - if (record.proxySessionId !== proxySessionId) { - this.records.delete(profileId) - record.window.destroy() - if (record.proxySessionId) this.cleanupProxySession(record.proxySessionId) - return undefined - } - if (record.window.isMinimized()) record.window.restore() - record.window.show() - record.window.focus() - return record.window - } - - register(profileId: string, window: BrowserWindow, proxySessionId?: string): void { - const record = { window, proxySessionId } - this.records.set(profileId, record) - window.on("closed", () => { - if (this.records.get(profileId) !== record) return - this.records.delete(profileId) - if (proxySessionId) this.cleanupProxySession(proxySessionId) - }) - } -} - -interface RemoteNavigationAuthority { - generation: number - trustedOrigins: Set - insecureOrigins: Set -} - -const navigationAuthorities = new WeakMap() - -export async function navigateRemoteWindow( - window: BrowserWindow, - target: URL, - nextOrigins: ReadonlySet, - trustedOrigins: Map>, - insecureOrigins: Map>, - skipTlsVerify: boolean, -): Promise { - let authority = navigationAuthorities.get(window) - if (!authority) { - authority = { - generation: 0, - trustedOrigins: new Set(trustedOrigins.get(window.id)), - insecureOrigins: new Set(insecureOrigins.get(window.webContents.id)), - } - navigationAuthorities.set(window, authority) - } - const generation = ++authority.generation - const committedOrigins = new Set(nextOrigins) - trustedOrigins.set(window.id, new Set([...authority.trustedOrigins, ...committedOrigins])) - const provisionalInsecure = new Set(authority.insecureOrigins) - if (skipTlsVerify) for (const origin of committedOrigins) provisionalInsecure.add(origin) - if (provisionalInsecure.size) insecureOrigins.set(window.webContents.id, provisionalInsecure) - else insecureOrigins.delete(window.webContents.id) - - try { await window.loadURL(target.toString()) } catch (error) { - if (authority.generation !== generation) return - if (authority.trustedOrigins.size) trustedOrigins.set(window.id, new Set(authority.trustedOrigins)) - else trustedOrigins.delete(window.id) - if (authority.insecureOrigins.size) insecureOrigins.set(window.webContents.id, new Set(authority.insecureOrigins)) - else insecureOrigins.delete(window.webContents.id) - throw error - } - - if (authority.generation !== generation) return - authority.trustedOrigins = committedOrigins - authority.insecureOrigins = skipTlsVerify ? new Set(committedOrigins) : new Set() - trustedOrigins.set(window.id, committedOrigins) - if (authority.insecureOrigins.size) insecureOrigins.set(window.webContents.id, new Set(authority.insecureOrigins)) - else insecureOrigins.delete(window.webContents.id) -} diff --git a/packages/electron-app/electron/main/startup.test.ts b/packages/electron-app/electron/main/startup.test.ts index 1241728bb..529f89307 100644 --- a/packages/electron-app/electron/main/startup.test.ts +++ b/packages/electron-app/electron/main/startup.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" -import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, prepareSecondLaunchIntent, resolveRemoteSessionPartition, resolveStorageScope, resolveUpdateChannel, startPrimaryInstance } from "./startup" +import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, parseLaunchIntent, prepareSecondLaunchIntent, resolveStorageScope, resolveUpdateChannel, startPrimaryInstance } from "./startup" test("update channel honors the environment, forces unpackaged dev, and only infers packaged versions", () => { assert.equal(resolveUpdateChannel("Beta", "1.0.0-dev.2", false), "beta") @@ -29,17 +29,6 @@ test("stable default storage preserves paths while dev and alternate configs are assert.equal(resolveStorageScope({ appVersion: "1.0.0", cliConfig: "other/config.yaml", cwd: base, baseUserDataPath: base, packaged: true }).userDataPath, alternate.userDataPath) }) -test("remote profiles use isolated persistent partitions and TLS exceptions stay with their webContents", () => { - const first = resolveRemoteSessionPartition("profile-a") - assert.match(first, /^persist:codenomad-remote-[0-9a-f]{24}$/) - assert.equal(resolveRemoteSessionPartition("profile-a"), first) - assert.notEqual(resolveRemoteSessionPartition("profile-b"), first) - assert.match(resolveRemoteSessionPartition("profile-a", "proxy-1"), /^codenomad-remote-/) - const allowlists = new Map([[7, new Set(["https://unsafe.example"])], [8, new Set(["https://other.example"])]] as const) - assert.equal(isRemoteCertificateAllowed(7, "https://unsafe.example/path", allowlists), true) - assert.equal(isRemoteCertificateAllowed(8, "https://unsafe.example/path", allowlists), false) -}) - test("new local windows reuse retained records and otherwise fall back to ephemeral identities", async () => { let additions = 0 assert.deepEqual(await allocateLocalWindowIdentity(["retained"], () => false, async () => { additions++; return "new" }), { id: "retained", persisted: true }) diff --git a/packages/electron-app/electron/main/startup.ts b/packages/electron-app/electron/main/startup.ts index 1860d3d62..11f2fa631 100644 --- a/packages/electron-app/electron/main/startup.ts +++ b/packages/electron-app/electron/main/startup.ts @@ -131,20 +131,6 @@ export function resolveStorageScope(options: { } } -export function resolveRemoteSessionPartition(profileId: string, proxySessionId?: string): string { - const identity = proxySessionId ? `${profileId}\0${proxySessionId}` : profileId - const suffix = createHash("sha256").update(identity).digest("hex").slice(0, 24) - return `${proxySessionId ? "" : "persist:"}codenomad-remote-${suffix}` -} - -export function isRemoteCertificateAllowed( - webContentsId: number, - url: string, - insecureOrigins: ReadonlyMap>, -): boolean { - try { return insecureOrigins.get(webContentsId)?.has(new URL(url).origin) ?? false } catch { return false } -} - export async function allocateLocalWindowIdentity( persistedIds: readonly string[], isRegistered: (id: string) => boolean, diff --git a/packages/electron-app/electron/main/window-navigation.ts b/packages/electron-app/electron/main/window-navigation.ts new file mode 100644 index 000000000..9f6e14c7f --- /dev/null +++ b/packages/electron-app/electron/main/window-navigation.ts @@ -0,0 +1,37 @@ +import type { BrowserWindow } from "electron" + +interface NavigationAuthority { + generation: number + trustedOrigins: Set +} + +const navigationAuthorities = new WeakMap() + +export async function navigateTrustedWindow( + window: BrowserWindow, + target: URL, + nextOrigins: ReadonlySet, + trustedOrigins: Map>, +): Promise { + let authority = navigationAuthorities.get(window) + if (!authority) { + authority = { generation: 0, trustedOrigins: new Set(trustedOrigins.get(window.id)) } + navigationAuthorities.set(window, authority) + } + const generation = ++authority.generation + const committedOrigins = new Set(nextOrigins) + trustedOrigins.set(window.id, new Set([...authority.trustedOrigins, ...committedOrigins])) + + try { + await window.loadURL(target.toString()) + } catch (error) { + if (authority.generation !== generation) return + if (authority.trustedOrigins.size) trustedOrigins.set(window.id, new Set(authority.trustedOrigins)) + else trustedOrigins.delete(window.id) + throw error + } + + if (authority.generation !== generation) return + authority.trustedOrigins = committedOrigins + trustedOrigins.set(window.id, committedOrigins) +} diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index c5c41c9ec..e93e319b0 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -58,7 +58,6 @@ const localElectronAPI = { requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), - openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), openPreferences: (section, context) => ipcRenderer.invoke("preferences:open", section, context), minimizeWindow: () => ipcRenderer.invoke("preferences:minimize"), toggleMaximizeWindow: () => ipcRenderer.invoke("preferences:toggleMaximize"), @@ -88,7 +87,6 @@ const preferencesElectronAPI = { restartCli: localElectronAPI.restartCli, openDialog: localElectronAPI.openDialog, showNotification: localElectronAPI.showNotification, - openRemoteWindow: localElectronAPI.openRemoteWindow, getPreferencesSection: () => ipcRenderer.invoke("preferences:getSection"), getPreferencesRequest: () => ipcRenderer.invoke("preferences:getSection"), preferencesReady: () => ipcRenderer.invoke("preferences:ready"), diff --git a/packages/electron-app/electron/preload/index.test.ts b/packages/electron-app/electron/preload/index.test.ts index 69d3ecf7f..391174a73 100644 --- a/packages/electron-app/electron/preload/index.test.ts +++ b/packages/electron-app/electron/preload/index.test.ts @@ -57,7 +57,7 @@ test("Preferences preload exposes only section and frame controls", () => { const api = exposed.get("electronAPI") as Record assert.deepEqual(Object.keys(api), [ - "onCliStatus", "onCliError", "getCliStatus", "restartCli", "openDialog", "showNotification", "openRemoteWindow", + "onCliStatus", "onCliError", "getCliStatus", "restartCli", "openDialog", "showNotification", "getPreferencesSection", "getPreferencesRequest", "preferencesReady", "acceptPreferencesRequest", "resolvePreferencesTransition", "onPreferencesSection", "onPreferencesCloseRequested", "onPreferencesTransitionRequested", "minimizeWindow", "toggleMaximizeWindow", "closeWindow", diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 7730e4fec..2ff279231 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -24,7 +24,7 @@ "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/developer-mode.test.ts electron/main/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.test.ts electron/main/native-request.test.ts electron/main/navigation-security.test.ts electron/main/preferences-ipc.test.ts electron/main/preferences-window.test.ts electron/main/process-exit.test.ts electron/main/process-output.test.ts electron/main/process-stop.test.ts electron/main/remote-window-registry.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/startup.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts electron/preload/index.test.ts", + "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/developer-mode.test.ts electron/main/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.test.ts electron/main/native-request.test.ts electron/main/navigation-security.test.ts electron/main/preferences-ipc.test.ts electron/main/preferences-window.test.ts electron/main/process-exit.test.ts electron/main/process-output.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/startup.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts electron/preload/index.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", diff --git a/packages/remote-control-protocol/package.json b/packages/remote-control-protocol/package.json new file mode 100644 index 000000000..5a371260c --- /dev/null +++ b/packages/remote-control-protocol/package.json @@ -0,0 +1,23 @@ +{ + "name": "@codenomad/remote-control-protocol", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "prepare": "npm run build", + "test": "node --import tsx --test src/**/*.test.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "tsx": "^4.20.6", + "typescript": "^5.6.3" + } +} diff --git a/packages/remote-control-protocol/src/index.test.ts b/packages/remote-control-protocol/src/index.test.ts new file mode 100644 index 000000000..fe875ea73 --- /dev/null +++ b/packages/remote-control-protocol/src/index.test.ts @@ -0,0 +1,8 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { decodeBase64, encodeBase64 } from "./index" + +test("binary relay payloads round-trip without truncation", () => { + const input = Uint8Array.from({ length: 100_000 }, (_, index) => index % 251) + assert.deepEqual(decodeBase64(encodeBase64(input)), input) +}) diff --git a/packages/remote-control-protocol/src/index.ts b/packages/remote-control-protocol/src/index.ts new file mode 100644 index 000000000..7d1e168ef --- /dev/null +++ b/packages/remote-control-protocol/src/index.ts @@ -0,0 +1,87 @@ +export const REMOTE_CONTROL_PROTOCOL_VERSION = 1 as const + +export type HeaderEntries = Array<[string, string]> + +export type RelayToHostMessage = + | { type: "ready"; protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION } + | { + type: "http.request" + id: string + method: string + path: string + headers: HeaderEntries + body?: string + } + | { type: "http.cancel"; id: string } + | { + type: "socket.open" + id: string + path: string + headers: HeaderEntries + protocols: string[] + } + | { type: "socket.message"; id: string; data: string; binary: boolean } + | { type: "socket.close"; id: string; code?: number; reason?: string } + | { type: "ping"; at: number } + +export type HostToRelayMessage = + | { type: "ready"; protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION } + | { type: "pong"; at: number } + | { + type: "http.start" + id: string + status: number + headers: HeaderEntries + } + | { type: "http.chunk"; id: string; data: string } + | { type: "http.end"; id: string } + | { type: "http.error"; id: string; message: string } + | { type: "socket.ready"; id: string; protocol?: string } + | { type: "socket.message"; id: string; data: string; binary: boolean } + | { type: "socket.close"; id: string; code?: number; reason?: string } + | { type: "socket.error"; id: string; message: string } + +export interface RemoteControlStatus { + manageable: boolean + enabled: boolean + state: "stopped" | "connecting" | "connected" | "reconnecting" | "error" + hostId: string + relayUrl: string + remoteUrl: string + pairedDevices: number + lastConnectedAt?: string + error?: string +} + +export interface RemoteControlPairing { + url: string + expiresAt: string +} + +export interface RemoteControlDevice { + id: string + name: string + createdAt: string + lastSeenAt: string +} + +export interface RemoteControlStartResponse { + status: RemoteControlStatus + pairing: RemoteControlPairing +} + +export function encodeBase64(bytes: Uint8Array): string { + let binary = "" + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} + +export function decodeBase64(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} diff --git a/packages/remote-control-protocol/tsconfig.json b/packages/remote-control-protocol/tsconfig.json new file mode 100644 index 000000000..93e161fad --- /dev/null +++ b/packages/remote-control-protocol/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src/index.ts"] +} diff --git a/packages/server/README.md b/packages/server/README.md index 136a4e13b..5ffdbfaae 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,15 +1,15 @@ # CodeNomad Server -**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace. +**CodeNomad Server** is the engine behind the CodeNomad cockpit. It manages local workspace access and connects to OpenCode's shared global daemon. The server listens only on loopback; Remote Control reaches it through an authenticated outbound relay connection. ## Features & Capabilities -### 🌍 Deployment Freedom +### 🌍 Remote Control -- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop. -- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling. -- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal. -- **Always-On**: Run as a background service so your sessions are always ready when you connect. +- **No inbound exposure**: CodeNomad remains on `127.0.0.1`; no LAN port, VPN, NAT rule, or public OpenCode endpoint is needed. +- **Outbound relay**: The host establishes one persistent WebSocket connection to the configured Cloudflare relay. +- **Secure pairing**: One-time links and QR codes expire after ten minutes and issue revocable 30-day device credentials. +- **Multi-device**: Continue the same CodeNomad sessions from a paired browser, tablet, or laptop. ### ⚡️ Workspace Power @@ -45,10 +45,7 @@ To list all CLI options: npx @neuralnomads/codenomad --help ``` -On startup, CodeNomad prints two URLs: - -- `Local Connection URL : ...` (used by desktop shells) -- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled) +On startup, CodeNomad prints its loopback `Local Connection URL`. Enable Remote Control from Settings to create a pairing link. ### Install Globally @@ -84,7 +81,6 @@ You can configure the server using flags or environment variables: | `--tls-cert ` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. | | `--tls-ca ` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) | | `--tlsSANs ` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) | -| `--host ` | `CLI_HOST` | Interface to bind (default 127.0.0.1) | | `--workspace-root ` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. | | `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing | | `--config ` | `CLI_CONFIG` | Config file location | @@ -101,6 +97,8 @@ You can configure the server using flags or environment variables: | `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true`) | | `--ui-manifest-url ` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | +Remote Control uses `https://remote.codenomad.neuralnomads.ai` by default. Set `CODENOMAD_REMOTE_CONTROL_RELAY_URL` to use another compatible relay. + ### Dev Releases (Advanced) If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package: @@ -125,19 +123,15 @@ These environment variables control how CodeNomad checks for dev updates: codenomad --https=false --http=true ``` -- To run both HTTPS (for remote) and HTTP loopback (for desktop): +- To run both HTTPS and HTTP on loopback: ```sh codenomad --https=true --http=true ``` -### Remote Access Binding Rules +### Remote Control Network Model -- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`): - - HTTP listens on `127.0.0.1` only. - - HTTPS listens on `--host` (LAN/all interfaces). -- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`): - - Both HTTP and HTTPS listen on `127.0.0.1`. +Both HTTP and HTTPS listeners bind to `127.0.0.1`. Remote Control never forwards its device cookie to the local server: the outbound connector strips remote credentials and injects a dedicated internal CodeNomad session. OpenCode remains behind CodeNomad's existing authorization, workspace, Git, Yolo, and proxy boundaries. ### Self-Signed Certificates @@ -158,14 +152,12 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10" > 2. **Firefox:** Click **Advanced** → **Accept the Risk and Continue** > 3. **Alternative:** For local-only development without the warning, run with `--https=false --http=true` > -> **Note:** Only accept self-signed certificates for localhost/127.0.0.1 that you control. For remote hosts, use proper TLS certificates. +> Remote Control does not expose this certificate or require remote devices to trust it; relay traffic uses normal public HTTPS. ### Authentication - Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser. -- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated. - Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.). - If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API. +- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats loopback requests as authenticated. Use it only for isolated local development. #### Setting a password @@ -205,7 +197,7 @@ Manual creation of this file is not recommended unless you have a helper to gene ### Progressive Web App (PWA) -When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead. +CodeNomad can be installed as a PWA from a supported browser, including from a paired Remote Control URL. 1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.). 2. Click the install icon in the address bar, or use the browser menu → "Install CodeNomad". @@ -213,7 +205,7 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo > **TLS requirement** > Browsers require a secure (`https://`) connection for PWA installation. -> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA). +> Paired Remote Control URLs use the relay's public HTTPS certificate. ### Data Storage @@ -223,6 +215,7 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo - **CodeNomad instance data**: `~/.config/codenomad/instances/` - **OpenCode V2 sessions, messages, and service registration**: OpenCode's platform-default global locations. - **Desktop restore state**: `~/.codenomad/client-state/v2/` +- **Remote Control host identity**: `~/.config/codenomad/remote-control.json` (random host ID and secret; keep private) CodeNomad owns no private OpenCode port, database, service registration, or daemon PID. Configured allowed `server.environmentVariables` and the current `NODE_EXTRA_CA_CERTS` apply only when CodeNomad starts a missing daemon. Existing daemons are unchanged; legacy `OPENCODE_DB` and `XDG_STATE_HOME` ownership variables are ignored. WSL lifecycle commands run inside Linux and never inspect or signal Linux PIDs from Windows. diff --git a/packages/server/package.json b/packages/server/package.json index 88af97fe3..1cf3da2d5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,6 +17,7 @@ "codenomad": "dist/bin.js" }, "scripts": { + "prebuild": "npm run build --workspace @codenomad/remote-control-protocol", "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs", "build:ui": "npm run build --prefix ../ui", "prepare-ui": "node ./scripts/copy-ui-dist.mjs", @@ -24,6 +25,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@codenomad/remote-control-protocol": "0.1.0", "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 17e5cfbaf..ee629267c 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -398,41 +398,12 @@ export interface YoloStateResponse { enabled: boolean } -export interface RemoteServerProfile { - id: string - name: string - baseUrl: string - skipTlsVerify: boolean - createdAt: string - updatedAt: string - lastConnectedAt?: string -} - -export interface RemoteServerProbeRequest { - baseUrl: string - skipTlsVerify?: boolean -} - -export interface RemoteServerProbeResponse { - ok: boolean - reachable: boolean - normalizedUrl: string - skipTlsVerify: boolean - requiresAuth: boolean - authenticated: boolean - error?: string - errorCode?: string -} - -export interface RemoteProxySessionCreateRequest { - baseUrl: string - skipTlsVerify?: boolean -} - -export interface RemoteProxySessionCreateResponse { - sessionId: string - windowUrl: string -} +export type { + RemoteControlDevice, + RemoteControlPairing, + RemoteControlStartResponse, + RemoteControlStatus, +} from "@codenomad/remote-control-protocol" export type WorkspaceEventType = | "workspace.created" @@ -466,14 +437,6 @@ export type WorkspaceEventPayload = | { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean } | { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string } -export interface NetworkAddress { - ip: string - family: "ipv4" | "ipv6" - scope: "external" | "internal" | "loopback" - /** Remote URL using the server's remote protocol/port for this IP. */ - remoteUrl: string -} - export interface LatestReleaseInfo { version: string tag: string @@ -499,24 +462,16 @@ export interface SupportMeta { export interface ServerMeta { /** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */ localUrl: string - /** URL remote clients should use (prefers HTTPS when enabled). */ - remoteUrl?: string /** SSE endpoint advertised to clients (`/api/events` by default). */ eventsUrl: string - /** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */ + /** Loopback host the server is bound to. */ host: string - /** Listening mode derived from host binding. */ - listeningMode: "local" | "all" /** Actual local port in use after binding. */ localPort: number - /** Actual remote port in use after binding (when remoteUrl is set). */ - remotePort?: number /** Display label for the host (e.g., hostname or friendly name). */ hostLabel: string /** Absolute path of the filesystem root exposed to clients. */ workspaceRoot: string - /** Reachable addresses for this server, external first. */ - addresses: NetworkAddress[] serverVersion?: string ui?: UiMeta support?: SupportMeta diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index c2ca2a0e0..76c802b74 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -26,7 +26,6 @@ const PreferencesSchema = z showUsageMetrics: z.boolean().default(true), usageMetricsExpansion: z.enum(["expanded", "collapsed"]).default("collapsed"), autoCleanupBlankSessions: z.boolean().default(true), - listeningMode: z.enum(["local", "all"]).default("local"), logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"), // OS notifications diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1ab65f872..55f44b079 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -21,8 +21,6 @@ import { launchInBrowser } from "./launcher" import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { resolveHttpsOptions } from "./server/tls" -import { RemoteProxySessionManager } from "./server/remote-proxy" -import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { resolveAutomationBridgeUrl, resolvePluginBaseUrl } from "./server/listener-base-url" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" @@ -36,6 +34,8 @@ import { createOpencodePermissionReplier } from "./permissions/opencode-replier" import { createOpencodeYoloPersistence } from "./permissions/opencode-yolo-metadata" import { NativeParent } from "./native-parent" import { AUTOMATION_BRIDGE_PATH, createAutomationBridgeRegistration, publishAutomationBridge, removeLegacyAutomationPlugin } from "./opencode/automation-plugin" +import { loadOrCreateRemoteControlIdentity } from "./remote-control/identity" +import { RemoteControlManager } from "./remote-control/manager" const require = createRequire(import.meta.url) @@ -45,7 +45,6 @@ const __dirname = path.dirname(__filename) const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public") interface CliOptions { - host: string https: boolean http: boolean httpsPort: number @@ -77,6 +76,7 @@ const DEFAULT_HOST = "127.0.0.1" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_HTTPS_PORT = 9898 const DEFAULT_HTTP_PORT = 9899 +const DEFAULT_REMOTE_CONTROL_RELAY_URL = "https://remote.codenomad.neuralnomads.ai" export const STDIN_SHUTDOWN_COMMAND = "codenomad:shutdown" interface ShutdownSignalSource { @@ -129,7 +129,6 @@ function parseCliOptions(argv: string[]): CliOptions { .name("codenomad") .description("CodeNomad CLI server") .version(packageJson.version, "-v, --version", "Show the CLI version") - .addOption(new Option("--host ", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST)) .addOption(new Option("--https ", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true")) .addOption(new Option("--http ", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false")) .addOption(new Option("--https-port ", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort)) @@ -173,7 +172,7 @@ function parseCliOptions(argv: string[]): CliOptions { .addOption( new Option( "--dangerously-skip-auth", - "Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).", + "Disable CodeNomad's internal auth. Use only for isolated local development.", ) .env("CODENOMAD_SKIP_AUTH") .default(false), @@ -182,7 +181,6 @@ function parseCliOptions(argv: string[]): CliOptions { program.parse(argv, { from: "user" }) const parsed = program.opts<{ - host: string https?: string http?: string httpsPort: number @@ -219,8 +217,6 @@ function parseCliOptions(argv: string[]): CliOptions { const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() - const normalizedHost = resolveHost(parsed.host) - const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase() const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes" @@ -232,7 +228,6 @@ function parseCliOptions(argv: string[]): CliOptions { } return { - host: normalizedHost, https: httpsEnabled, http: httpEnabled, httpsPort: parsed.httpsPort, @@ -269,21 +264,6 @@ function parsePort(input: string): number { return value } -function resolveHost(input: string | undefined): string { - const trimmed = input?.trim() - if (!trimmed) return DEFAULT_HOST - - if (trimmed === "0.0.0.0") { - return "0.0.0.0" - } - - if (trimmed === "localhost") { - return DEFAULT_HOST - } - - return trimmed -} - export function programHasArg(argv: string[], flag: string): boolean { return argv.some((argument) => argument === flag || argument.startsWith(`${flag}=`)) } @@ -316,8 +296,6 @@ async function main() { const eventBus = new EventBus(eventLogger) - const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") - const configLocation = resolveConfigLocation(options.configPath) const configDir = configLocation.baseDir @@ -327,15 +305,11 @@ async function main() { const serverMeta: ServerMeta = { localUrl: "http://localhost:0", - remoteUrl: undefined, eventsUrl: `/api/events`, - host: options.host, - listeningMode: isLoopbackHost(options.host) ? "local" : "all", + host: DEFAULT_HOST, localPort: 0, - remotePort: undefined, - hostLabel: options.host, + hostLabel: DEFAULT_HOST, workspaceRoot: options.rootDir, - addresses: [], } const authManager = new AuthManager( @@ -360,7 +334,7 @@ async function main() { const tlsResolution = resolveHttpsOptions({ enabled: options.https, configDir, - host: options.host, + host: DEFAULT_HOST, tlsKeyPath: options.tlsKeyPath, tlsCertPath: options.tlsCertPath, tlsCaPath: options.tlsCaPath, @@ -458,13 +432,14 @@ async function main() { }) : null - const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host) - const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" })) - const remoteProxySessionManager = new RemoteProxySessionManager({ - authManager, - logger: logger.child({ component: "remote-proxy" }), - httpsOptions: tlsResolution?.httpsOptions, + const remoteControlSession = authManager.createSession(options.authUsername) + const remoteControlManager = new RemoteControlManager({ + identity: loadOrCreateRemoteControlIdentity(configDir), + relayUrl: process.env.CODENOMAD_REMOTE_CONTROL_RELAY_URL ?? DEFAULT_REMOTE_CONTROL_RELAY_URL, + localUrl: () => serverMeta.localUrl, + localCookie: () => `${authManager.getCookieName()}=${encodeURIComponent(remoteControlSession.id)}`, + logger: logger.child({ component: "remote-control" }), }) const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT) const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT) @@ -472,12 +447,10 @@ async function main() { const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0 const httpBindPort = httpPortExplicit ? options.httpPort : 0 - // Listener binding rules: - // - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP). - // - Remote access disabled: both listen on loopback. - // - HTTP-only mode: respect --host (used for dev/testing). - const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1" - const httpBindHost = nativeParent.available ? "127.0.0.1" : options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1" + // Remote Control uses an outbound relay connection. CodeNomad itself never + // accepts connections from a LAN or public interface. + const httpsBindHost = DEFAULT_HOST + const httpBindHost = DEFAULT_HOST const servers: Array> = [] @@ -498,7 +471,7 @@ async function main() { previewManager, authManager, clientConnectionManager, - remoteProxySessionManager, + remoteControlManager, yoloManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, @@ -526,7 +499,7 @@ async function main() { previewManager, authManager, clientConnectionManager, - remoteProxySessionManager, + remoteControlManager, yoloManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, @@ -550,43 +523,14 @@ async function main() { throw new Error("No listeners started") } - const remoteStart = httpsStart ?? httpStart - const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http" - - let remoteUrl: string | undefined - let remoteAddresses = [] as ReturnType - if (remoteStart) { - const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host) - let remoteHost = options.host - if (wantsAll) { - if (options.host === "0.0.0.0") { - const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) - remoteAddresses = resolved.userVisible - remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}` - } - } else { - remoteHost = "localhost" - } - if (!remoteUrl) { - remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}` - } - } - - // Prefer an explicit IPv4 loopback address only when one of the bound listeners - // accepts loopback. Concrete LAN bindings do not, so plugins need the reachable - // bound/listener URL instead of an unreachable 127.0.0.1 URL. const localUrl = resolvePluginBaseUrl({ httpStart: visibleHttpStart ? { protocol: "http", bindHost: httpBindHost, port: visibleHttpStart.port } : null, httpsStart: httpsStart ? { protocol: "https", bindHost: httpsBindHost, port: httpsStart.port } : null, - remoteUrl, }) serverMeta.localUrl = localUrl serverMeta.localPort = localStart.port - serverMeta.remoteUrl = remoteUrl - serverMeta.remotePort = remoteStart?.port - serverMeta.host = options.host - serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local" + serverMeta.host = DEFAULT_HOST let removeAutomationBridge: (() => Promise) | undefined if (nativeParent.available && process.env.CODENOMAD_DEVELOPER_MODE === "1") { @@ -602,28 +546,7 @@ async function main() { } } - if (serverMeta.remotePort && remoteUrl) { - serverMeta.addresses = remoteAddresses.length - ? remoteAddresses - : resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort }) - } else { - serverMeta.addresses = [] - } - console.log(`Local Connection URL : ${serverMeta.localUrl}`) - if (serverMeta.remoteUrl) { - console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`) - const additionalRemoteUrls = serverMeta.addresses - .map((addr) => addr.remoteUrl) - .filter((url) => url !== serverMeta.remoteUrl) - - if (additionalRemoteUrls.length > 0) { - console.log("Other Accessible URLs:") - for (const url of additionalRemoteUrls) { - console.log(` - ${url}`) - } - } - } if (options.launch) { await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" })) @@ -642,7 +565,7 @@ async function main() { stopInstanceEventBridge: () => instanceEventBridge.shutdown(), stopSidecars: () => sidecarManager.shutdown(), stopClientConnections: () => clientConnectionManager.shutdown(), - stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(), + stopRemoteControl: () => remoteControlManager.shutdown(), stopWorkspaces: () => workspaceManager.shutdown(), stopHttpServers: async () => { nativeParent.close() diff --git a/packages/server/src/remote-control/connector.test.ts b/packages/server/src/remote-control/connector.test.ts new file mode 100644 index 000000000..f1adae33b --- /dev/null +++ b/packages/server/src/remote-control/connector.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { normalizedRelayUrl } from "./connector" + +test("Remote Control relays require HTTPS except for loopback development", () => { + assert.equal(normalizedRelayUrl("https://relay.example/path?ignored=1").href, "https://relay.example/") + assert.equal(normalizedRelayUrl("http://127.0.0.1:8787").href, "http://127.0.0.1:8787/") + assert.equal(normalizedRelayUrl("http://localhost:8787").href, "http://localhost:8787/") + assert.throws(() => normalizedRelayUrl("http://relay.example"), /must use HTTPS/) + assert.throws(() => normalizedRelayUrl("file:///relay"), /must use HTTPS/) +}) diff --git a/packages/server/src/remote-control/connector.ts b/packages/server/src/remote-control/connector.ts new file mode 100644 index 000000000..165021ce3 --- /dev/null +++ b/packages/server/src/remote-control/connector.ts @@ -0,0 +1,309 @@ +import { + REMOTE_CONTROL_PROTOCOL_VERSION, + decodeBase64, + encodeBase64, + type HeaderEntries, + type HostToRelayMessage, + type RelayToHostMessage, +} from "@codenomad/remote-control-protocol" +import { Agent, fetch, WebSocket } from "undici" +import type { Logger } from "../logger" + +const INITIAL_RECONNECT_MS = 1_000 +const MAX_RECONNECT_MS = 30_000 +const MAX_QUEUED_SOCKET_MESSAGES = 256 +const RELAY_HANDSHAKE_TIMEOUT_MS = 15_000 +const RESPONSE_HEADER_BLOCKLIST = new Set(["connection", "content-encoding", "content-length", "set-cookie", "transfer-encoding", "upgrade"]) +const REQUEST_HEADER_BLOCKLIST = new Set(["authorization", "connection", "cookie", "host", "proxy-authorization", "transfer-encoding", "upgrade"]) + +export type ConnectorState = "stopped" | "connecting" | "connected" | "reconnecting" | "error" + +interface ConnectorOptions { + relayUrl: string + hostId: string + secret: string + localUrl: () => string + localCookie: () => string + logger: Logger + onState: (state: ConnectorState, error?: string) => void +} + +export class RemoteControlConnector { + private socket: InstanceType | null = null + private reconnectTimer: NodeJS.Timeout | null = null + private handshakeTimer: NodeJS.Timeout | null = null + private ready = false + private desired = false + private reconnectDelay = INITIAL_RECONNECT_MS + private readonly httpRequests = new Map() + private readonly localSockets = new Map>() + private readonly localSocketQueues = new Map>() + private readonly localDispatcher = new Agent({ connect: { rejectUnauthorized: false } }) + + constructor(private readonly options: ConnectorOptions) {} + + start(): void { + if (this.desired) return + this.desired = true + this.reconnectDelay = INITIAL_RECONNECT_MS + this.connect("connecting") + } + + stop(): void { + this.desired = false + if (this.reconnectTimer) clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + if (this.handshakeTimer) clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + this.socket?.close(1000, "Remote Control stopped") + this.socket = null + this.ready = false + this.abortInflight() + this.options.onState("stopped") + } + + async shutdown(): Promise { + this.stop() + await this.localDispatcher.close().catch(() => undefined) + } + + isConnected(): boolean { + return this.ready && this.socket?.readyState === WebSocket.OPEN + } + + private connect(state: "connecting" | "reconnecting") { + if (!this.desired || this.socket) return + this.options.onState(state) + const url = relaySocketUrl(this.options.relayUrl, this.options.hostId) + const socket = new WebSocket(url, { + headers: { Authorization: `Bearer ${this.options.secret}` }, + }) + this.socket = socket + this.ready = false + socket.addEventListener("open", () => { + if (this.socket !== socket) return + this.reconnectDelay = INITIAL_RECONNECT_MS + this.send({ type: "ready", protocol: REMOTE_CONTROL_PROTOCOL_VERSION }) + this.handshakeTimer = setTimeout(() => socket.close(1002, "Remote Control relay handshake timed out"), RELAY_HANDSHAKE_TIMEOUT_MS) + this.handshakeTimer.unref() + }) + socket.addEventListener("message", (event) => void this.onMessage(event.data).catch((error) => { + this.options.logger.warn({ err: error }, "Remote Control message failed") + })) + socket.addEventListener("close", () => this.onClosed(socket)) + socket.addEventListener("error", () => this.onClosed(socket, "Unable to connect to the Remote Control relay")) + } + + private onClosed(socket: InstanceType, error?: string) { + if (this.socket !== socket) return + this.socket = null + this.ready = false + if (this.handshakeTimer) clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + this.abortInflight() + if (!this.desired) { + this.options.onState("stopped") + return + } + this.options.onState(error ? "error" : "reconnecting", error) + if (this.reconnectTimer) clearTimeout(this.reconnectTimer) + const delay = this.reconnectDelay + this.reconnectDelay = Math.min(MAX_RECONNECT_MS, this.reconnectDelay * 2) + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connect("reconnecting") + }, delay) + this.reconnectTimer.unref() + } + + private async onMessage(data: unknown) { + const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? new TextDecoder().decode(data) : "" + if (!text) return + const message = JSON.parse(text) as RelayToHostMessage + if (message.type === "ready") { + if (message.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) { + this.socket?.close(1002, "Unsupported Remote Control protocol") + return + } + if (this.handshakeTimer) clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + this.ready = true + this.options.onState("connected") + return + } + if (message.type === "ping") return this.send({ type: "pong", at: message.at }) + if (message.type === "http.request") return this.handleHttp(message) + if (message.type === "http.cancel") return this.cancelHttp(message.id) + if (message.type === "socket.open") return this.openSocket(message) + if (message.type === "socket.message") return this.forwardSocketMessage(message) + if (message.type === "socket.close") return this.closeSocket(message.id, message.code, message.reason) + } + + private async handleHttp(message: Extract) { + const controller = new AbortController() + this.httpRequests.set(message.id, controller) + try { + const target = this.localTarget(message.path) + const headers = localHeaders(message.headers, this.options.localCookie()) + const response = await fetch(target, { + method: message.method, + headers, + body: message.body ? decodeBase64(message.body) : undefined, + dispatcher: this.localDispatcher, + signal: controller.signal, + redirect: "manual", + }) + this.send({ + type: "http.start", + id: message.id, + status: response.status, + headers: responseHeaders(response.headers), + }) + if (response.body && message.method !== "HEAD") { + const reader = response.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + if (value.byteLength) this.send({ type: "http.chunk", id: message.id, data: encodeBase64(value) }) + } + } + this.send({ type: "http.end", id: message.id }) + } catch (error) { + if (!controller.signal.aborted) { + this.send({ type: "http.error", id: message.id, message: error instanceof Error ? error.message : "Local request failed" }) + } + } finally { + this.httpRequests.delete(message.id) + } + } + + private cancelHttp(id: string) { + this.httpRequests.get(id)?.abort() + this.httpRequests.delete(id) + } + + private openSocket(message: Extract) { + try { + const target = this.localTarget(message.path) + target.protocol = target.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket(target, { + protocols: message.protocols, + dispatcher: this.localDispatcher, + headers: localHeaders(message.headers, this.options.localCookie()), + }) + socket.binaryType = "arraybuffer" + this.localSockets.set(message.id, socket) + this.localSocketQueues.set(message.id, []) + socket.addEventListener("open", () => { + this.send({ type: "socket.ready", id: message.id, ...(socket.protocol ? { protocol: socket.protocol } : {}) }) + const queued = this.localSocketQueues.get(message.id) ?? [] + this.localSocketQueues.delete(message.id) + for (const entry of queued) this.sendLocalSocket(socket, entry.data, entry.binary) + }) + socket.addEventListener("message", (event) => { + const binary = typeof event.data !== "string" + const bytes = binary ? new Uint8Array(event.data as ArrayBuffer) : new TextEncoder().encode(event.data as string) + this.send({ type: "socket.message", id: message.id, data: encodeBase64(bytes), binary }) + }) + socket.addEventListener("close", (event) => { + this.localSockets.delete(message.id) + this.localSocketQueues.delete(message.id) + this.send({ type: "socket.close", id: message.id, code: event.code, reason: event.reason }) + }) + socket.addEventListener("error", () => { + this.localSockets.delete(message.id) + this.localSocketQueues.delete(message.id) + this.send({ type: "socket.error", id: message.id, message: "Local WebSocket failed" }) + }) + } catch (error) { + this.send({ type: "socket.error", id: message.id, message: error instanceof Error ? error.message : "Local WebSocket failed" }) + } + } + + private forwardSocketMessage(message: Extract) { + const socket = this.localSockets.get(message.id) + if (!socket) return + if (socket.readyState === WebSocket.CONNECTING) { + const queued = this.localSocketQueues.get(message.id) + if (!queued || queued.length >= MAX_QUEUED_SOCKET_MESSAGES) { + this.closeSocket(message.id, 1009, "Too many queued Remote Control messages") + return + } + queued.push({ data: message.data, binary: message.binary }) + return + } + if (socket.readyState === WebSocket.OPEN) this.sendLocalSocket(socket, message.data, message.binary) + } + + private sendLocalSocket(socket: InstanceType, data: string, binary: boolean) { + const bytes = decodeBase64(data) + socket.send(binary ? bytes : new TextDecoder().decode(bytes)) + } + + private closeSocket(id: string, code?: number, reason?: string) { + const socket = this.localSockets.get(id) + this.localSockets.delete(id) + this.localSocketQueues.delete(id) + socket?.close(code, reason) + } + + private localTarget(path: string): URL { + if (!path.startsWith("/") || path.startsWith("//")) throw new Error("Invalid remote request path") + const base = new URL(this.options.localUrl()) + const target = new URL(path, base) + if (target.origin !== base.origin) throw new Error("Remote request escaped the local server") + return target + } + + private send(message: HostToRelayMessage) { + if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)) + } + + private abortInflight() { + for (const controller of this.httpRequests.values()) controller.abort() + this.httpRequests.clear() + for (const socket of this.localSockets.values()) socket.close(1012, "Remote Control reconnecting") + this.localSockets.clear() + this.localSocketQueues.clear() + } +} + +function relaySocketUrl(relayUrl: string, hostId: string): URL { + const url = new URL(`/api/hosts/${hostId}/connect`, normalizedRelayUrl(relayUrl)) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + return url +} + +export function normalizedRelayUrl(value: string): URL { + const url = new URL(value) + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) { + throw new Error("Remote Control relay must use HTTPS") + } + url.pathname = "/" + url.search = "" + url.hash = "" + return url +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase() + return normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.startsWith("127.") +} + +function localHeaders(entries: HeaderEntries, cookie: string): Headers { + const headers = new Headers() + for (const [name, value] of entries) { + if (!REQUEST_HEADER_BLOCKLIST.has(name.toLowerCase())) headers.append(name, value) + } + headers.set("Cookie", cookie) + headers.set("X-CodeNomad-Remote-Control", "1") + return headers +} + +function responseHeaders(headers: Headers): HeaderEntries { + const entries: HeaderEntries = [] + headers.forEach((value, name) => { + if (!RESPONSE_HEADER_BLOCKLIST.has(name.toLowerCase())) entries.push([name, value]) + }) + return entries +} diff --git a/packages/server/src/remote-control/identity.test.ts b/packages/server/src/remote-control/identity.test.ts new file mode 100644 index 000000000..f3a8ab72a --- /dev/null +++ b/packages/server/src/remote-control/identity.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import { loadOrCreateRemoteControlIdentity } from "./identity" + +test("Remote Control identity is random, persistent, and never stores malformed input", () => { + const directory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-")) + try { + const first = loadOrCreateRemoteControlIdentity(directory) + assert.match(first.hostId, /^[a-f0-9]{32}$/) + assert.match(first.secret, /^[A-Za-z0-9_-]{40,}$/) + assert.deepEqual(loadOrCreateRemoteControlIdentity(directory), first) + + const stored = JSON.parse(readFileSync(join(directory, "remote-control.json"), "utf8")) + assert.deepEqual(stored, first) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test("malformed existing identities are replaced atomically", () => { + const directory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-")) + try { + writeFileSync(join(directory, "remote-control.json"), JSON.stringify({ hostId: "predictable", secret: "short" })) + const identity = loadOrCreateRemoteControlIdentity(directory) + assert.notEqual(identity.hostId, "predictable") + assert.notEqual(identity.secret, "short") + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/server/src/remote-control/identity.ts b/packages/server/src/remote-control/identity.ts new file mode 100644 index 000000000..260a44b45 --- /dev/null +++ b/packages/server/src/remote-control/identity.ts @@ -0,0 +1,43 @@ +import { randomBytes } from "crypto" +import fs from "fs" +import path from "path" + +export interface RemoteControlIdentity { + hostId: string + secret: string +} + +const HOST_ID_PATTERN = /^[a-f0-9]{32}$/ +const SECRET_PATTERN = /^[A-Za-z0-9_-]{40,}$/ + +export function loadOrCreateRemoteControlIdentity(configDir: string): RemoteControlIdentity { + const filePath = path.join(configDir, "remote-control.json") + const existing = readIdentity(filePath) + if (existing) return existing + + const identity = { + hostId: randomBytes(16).toString("hex"), + secret: randomBytes(32).toString("base64url"), + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp` + fs.writeFileSync(temporary, `${JSON.stringify(identity, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }) + fs.renameSync(temporary, filePath) + try { + fs.chmodSync(filePath, 0o600) + } catch { + // Windows ACLs and some network filesystems do not implement POSIX modes. + } + return identity +} + +function readIdentity(filePath: string): RemoteControlIdentity | null { + try { + const value = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial + if (!HOST_ID_PATTERN.test(value.hostId ?? "") || !SECRET_PATTERN.test(value.secret ?? "")) return null + return { hostId: value.hostId!, secret: value.secret! } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null + throw error + } +} diff --git a/packages/server/src/remote-control/manager.ts b/packages/server/src/remote-control/manager.ts new file mode 100644 index 000000000..180967088 --- /dev/null +++ b/packages/server/src/remote-control/manager.ts @@ -0,0 +1,144 @@ +import type { + RemoteControlDevice, + RemoteControlPairing, + RemoteControlStartResponse, + RemoteControlStatus, +} from "@codenomad/remote-control-protocol" +import { fetch } from "undici" +import type { Logger } from "../logger" +import { RemoteControlConnector, normalizedRelayUrl, type ConnectorState } from "./connector" +import type { RemoteControlIdentity } from "./identity" + +interface ManagerOptions { + identity: RemoteControlIdentity + relayUrl: string + localUrl: () => string + localCookie: () => string + logger: Logger +} + +export class RemoteControlManager { + private state: ConnectorState = "stopped" + private error: string | undefined + private lastConnectedAt: string | undefined + private enabled = false + private pairedDevices = 0 + private readonly connector: RemoteControlConnector + + constructor(private readonly options: ManagerOptions) { + normalizedRelayUrl(options.relayUrl) + this.connector = new RemoteControlConnector({ + relayUrl: options.relayUrl, + hostId: options.identity.hostId, + secret: options.identity.secret, + localUrl: options.localUrl, + localCookie: options.localCookie, + logger: options.logger, + onState: (state, error) => { + this.state = state + this.error = error + if (state === "connected") this.lastConnectedAt = new Date().toISOString() + }, + }) + } + + status(): RemoteControlStatus { + const relay = normalizedRelayUrl(this.options.relayUrl) + return { + manageable: true, + enabled: this.enabled, + state: this.state, + hostId: this.options.identity.hostId, + relayUrl: relay.origin, + remoteUrl: remoteOrigin(relay, this.options.identity.hostId), + pairedDevices: this.pairedDevices, + ...(this.lastConnectedAt ? { lastConnectedAt: this.lastConnectedAt } : {}), + ...(this.error ? { error: this.error } : {}), + } + } + + async start(): Promise { + this.enabled = true + this.connector.start() + await this.waitForConnection() + const pairing = await this.createPairing() + return { status: this.status(), pairing } + } + + stop(): RemoteControlStatus { + this.enabled = false + this.connector.stop() + return this.status() + } + + async createPairing(): Promise { + if (!this.connector.isConnected()) throw new Error("Remote Control is not connected") + const relay = normalizedRelayUrl(this.options.relayUrl) + const response = await fetch(new URL(`/api/hosts/${this.options.identity.hostId}/pair`, relay), { + method: "POST", + headers: { Authorization: `Bearer ${this.options.identity.secret}` }, + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw new Error(await relayError(response, "Could not create a pairing link")) + const payload = await response.json() as { token?: unknown; expiresAt?: unknown } + if (typeof payload.token !== "string" || typeof payload.expiresAt !== "string") throw new Error("Relay returned an invalid pairing link") + const origin = remoteOrigin(relay, this.options.identity.hostId) + return { url: `${origin}/__codenomad/pair#${encodeURIComponent(payload.token)}`, expiresAt: payload.expiresAt } + } + + async devices(): Promise { + const response = await this.hostRequest("devices") + const payload = await response.json() as { devices?: unknown } + const devices = Array.isArray(payload.devices) ? payload.devices as RemoteControlDevice[] : [] + this.pairedDevices = devices.length + return devices + } + + async revokeDevice(deviceId: string): Promise { + const response = await this.hostRequest(`devices/${encodeURIComponent(deviceId)}`, "DELETE") + if (!response.ok) throw new Error(await relayError(response, "Could not revoke the remote device")) + this.pairedDevices = Math.max(0, this.pairedDevices - 1) + } + + shutdown(): Promise { + this.enabled = false + return this.connector.shutdown() + } + + private async hostRequest(path: string, method = "GET") { + const relay = normalizedRelayUrl(this.options.relayUrl) + const response = await fetch(new URL(`/api/hosts/${this.options.identity.hostId}/${path}`, relay), { + method, + headers: { Authorization: `Bearer ${this.options.identity.secret}` }, + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw new Error(await relayError(response, "Remote Control relay request failed")) + return response + } + + private waitForConnection(timeoutMs = 10_000): Promise { + if (this.connector.isConnected()) return Promise.resolve() + const started = Date.now() + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + if (this.connector.isConnected()) { + clearInterval(timer) + resolve() + } else if (Date.now() - started >= timeoutMs) { + clearInterval(timer) + reject(new Error(this.error ?? "Timed out connecting to the Remote Control relay")) + } + }, 50) + timer.unref() + }) + } +} + +function remoteOrigin(relay: URL, hostId: string): string { + return `${relay.protocol}//${hostId}.${relay.host}` +} + +async function relayError(response: { json: () => Promise; status: number }, fallback: string): Promise { + const payload = await response.json().catch(() => null) as { error?: unknown } | null + return typeof payload?.error === "string" ? payload.error : `${fallback} (HTTP ${response.status})` +} diff --git a/packages/server/src/server/__tests__/listener-base-url.test.ts b/packages/server/src/server/__tests__/listener-base-url.test.ts index ddd3161a2..289fe3c5c 100644 --- a/packages/server/src/server/__tests__/listener-base-url.test.ts +++ b/packages/server/src/server/__tests__/listener-base-url.test.ts @@ -8,27 +8,24 @@ describe("resolvePluginBaseUrl", () => { assert.equal( resolvePluginBaseUrl({ httpsStart: { protocol: "https", bindHost: "127.0.0.1", port: 9898 }, - remoteUrl: "https://localhost:9898", }), "https://127.0.0.1:9898", ) }) - it("uses the concrete LAN listener when no loopback listener exists", () => { + it("uses the concrete HTTPS listener when no HTTP listener exists", () => { assert.equal( resolvePluginBaseUrl({ httpsStart: { protocol: "https", bindHost: "192.168.1.25", port: 9898 }, - remoteUrl: "https://192.168.1.25:9898", }), "https://192.168.1.25:9898", ) }) - it("prefers loopback for wildcard listeners because 0.0.0.0 accepts loopback", () => { + it("resolves wildcard listeners to their loopback URL", () => { assert.equal( resolvePluginBaseUrl({ httpsStart: { protocol: "https", bindHost: "0.0.0.0", port: 9898 }, - remoteUrl: "https://192.168.1.25:9898", }), "https://127.0.0.1:9898", ) @@ -39,7 +36,6 @@ describe("resolvePluginBaseUrl", () => { resolvePluginBaseUrl({ httpStart: { protocol: "http", bindHost: "127.0.0.1", port: 9899 }, httpsStart: { protocol: "https", bindHost: "192.168.1.25", port: 9898 }, - remoteUrl: "https://192.168.1.25:9898", }), "http://127.0.0.1:9899", ) diff --git a/packages/server/src/server/__tests__/network-addresses.test.ts b/packages/server/src/server/__tests__/network-addresses.test.ts deleted file mode 100644 index a6d477670..000000000 --- a/packages/server/src/server/__tests__/network-addresses.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import assert from "node:assert/strict" -import os from "node:os" -import { describe, it } from "node:test" - -import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses" - -describe("resolveNetworkAddresses", () => { - it("preserves interface order among external addresses", () => { - const addresses = [ - { address: "172.24.0.1", family: "IPv4", internal: false }, - { address: "192.168.1.128", family: "IPv4", internal: false }, - { address: "10.0.0.8", family: 4, internal: false }, - { address: "127.0.0.1", family: "IPv4", internal: true }, - { address: "169.254.10.20", family: "IPv4", internal: false }, - ] - - usingMockedNetworkInterfaces(addresses, () => { - const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) - - assert.deepEqual( - result.map((entry) => entry.ip), - ["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"], - ) - }) - }) -}) - -describe("resolveRemoteAddresses", () => { - it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => { - const addresses = [ - { address: "169.254.10.20", family: "IPv4", internal: false }, - { address: "192.168.1.128", family: "IPv4", internal: false }, - { address: "172.24.0.1", family: "IPv4", internal: false }, - ] - - usingMockedNetworkInterfaces(addresses, () => { - const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) - - assert.deepEqual( - result.userVisible.map((entry) => entry.ip), - ["192.168.1.128", "172.24.0.1", "169.254.10.20"], - ) - assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898") - }) - }) - - it("prefers private LAN addresses over public addresses", () => { - const addresses = [ - { address: "203.0.113.40", family: "IPv4", internal: false }, - { address: "192.168.1.128", family: "IPv4", internal: false }, - { address: "8.8.8.8", family: "IPv4", internal: false }, - ] - - usingMockedNetworkInterfaces(addresses, () => { - const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) - - assert.deepEqual( - result.userVisible.map((entry) => entry.ip), - ["192.168.1.128", "203.0.113.40", "8.8.8.8"], - ) - assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898") - }) - }) - - it("uses a public address when no private LAN address is available", () => { - const addresses = [ - { address: "169.254.10.20", family: "IPv4", internal: false }, - { address: "203.0.113.40", family: "IPv4", internal: false }, - ] - - usingMockedNetworkInterfaces(addresses, () => { - const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) - - assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"]) - assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898") - }) - }) -}) - -function usingMockedNetworkInterfaces( - addresses: Array<{ address: string; family: string | number; internal: boolean }>, - callback: () => void, -) { - const original = os.networkInterfaces - os.networkInterfaces = (() => ({ - ethernet0: addresses as unknown as ReturnType[string], - })) as typeof os.networkInterfaces - - try { - callback() - } finally { - os.networkInterfaces = original - } -} diff --git a/packages/server/src/server/__tests__/remote-proxy.test.ts b/packages/server/src/server/__tests__/remote-proxy.test.ts deleted file mode 100644 index f4e5053d9..000000000 --- a/packages/server/src/server/__tests__/remote-proxy.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import assert from "node:assert/strict" -import { after, afterEach, describe, it } from "node:test" -import fs from "node:fs" -import http, { type IncomingMessage, type ServerResponse } from "node:http" -import os from "node:os" -import path from "node:path" - -import { Agent, fetch } from "undici" - -import type { AuthManager } from "../../auth/manager" -import type { Logger } from "../../logger" -import { RemoteProxySessionManager } from "../remote-proxy" -import { resolveHttpsOptions } from "../tls" - -const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-")) -const sharedTls = resolveHttpsOptions({ enabled: true, configDir: sharedTempDir, host: "127.0.0.1", logger: createStubLogger() }) -if (!sharedTls) throw new Error("Failed to generate HTTPS options for remote proxy tests") -const sharedHttpsOptions = sharedTls.httpsOptions -const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } }) -const managers = new Set() - -afterEach(async () => { - for (const manager of managers) await manager.shutdown().catch(() => undefined) - managers.clear() -}) - -after(async () => { - fs.rmSync(sharedTempDir, { recursive: true, force: true }) - await httpsDispatcher.destroy().catch(() => {}) -}) - -describe("RemoteProxySessionManager", () => { - it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const session1 = await createSession(manager, `${upstreamBaseUrl}/base`) - const session2 = await createSession(manager, `${upstreamBaseUrl}/base`) - const blocked = await proxyFetch(`${session1.proxyOrigin}/status`) - assert.equal(blocked.status, 403) - const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ token: session2.token }), - }) - assert.equal(wrongTokenResponse.status, 401) - - assert.equal(await activateSession(session1), true) - assert.equal(await activateSession(session2), true) - }, (req, res) => { - res.writeHead(200, { "content-type": "text/plain" }) - res.end(req.url ?? "") - }) - }) - - it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const session = await createSession(manager, `${upstreamBaseUrl}/base`) - await activateSession(session) - const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`) - assert.equal(apiResponse.status, 200) - assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar") - - const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" }) - assert.equal(redirectResponse.status, 302) - assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`) - }, (req, res) => { - const requestUrl = req.url ?? "" - if (requestUrl === "/base/redirect") { - res.writeHead(302, { location: "/base/after?ok=1" }) - return res.end() - } - res.writeHead(200, { "content-type": "text/plain" }) - res.end(requestUrl) - }) - }) - - it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const session = await createSession(manager, `${upstreamBaseUrl}/base`) - await activateSession(session) - const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`) - assert.equal(loginResponse.status, 200) - const setCookie = getSetCookie(loginResponse)[0] - - assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i) - assert.doesNotMatch(setCookie, /domain=/i) - const cookieHeader = setCookie.split(";", 1)[0] - const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, { - headers: { cookie: cookieHeader }, - }) - assert.equal(await whoamiResponse.text(), "session=abc123") - }, (req, res) => { - const requestUrl = req.url ?? "" - if (requestUrl === "/base/login") { - res.writeHead(200, { - "content-type": "text/plain", - "set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1", - }) - return res.end("ok") - } - if (requestUrl === "/base/whoami") { - res.writeHead(200, { "content-type": "text/plain" }) - return res.end(req.headers.cookie ?? "") - } - res.writeHead(404, { "content-type": "text/plain" }) - res.end(requestUrl) - }) - }) - - it("supports explicit deletion and idle cleanup of sessions", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const session = await createSession(manager, `${upstreamBaseUrl}/base`) - assert.equal(await manager.deleteSession(session.sessionId), true) - assert.equal(await manager.deleteSession(session.sessionId), false) - const session3 = await createSession(manager, `${upstreamBaseUrl}/base`) - const internalSessions = (manager as any).sessions as Map - const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise - internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000 - await internalCleanup.call(manager) - assert.equal(internalSessions.has(session3.sessionId), false) - assert.equal(await manager.deleteSession(session3.sessionId), false) - }, (_req, res) => { - res.writeHead(200, { "content-type": "text/plain" }) - res.end("ok") - }) - }) - - it("closes every session listener during shutdown", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const first = await createSession(manager, `${upstreamBaseUrl}/base`) - await createSession(manager, `${upstreamBaseUrl}/other`) - await manager.shutdown() - assert.equal((manager as any).sessions.size, 0) - await assert.rejects(proxyFetch(`${first.proxyOrigin}/status`)) - }, (_req, res) => { - res.writeHead(200).end("ok") - }) - }) - - it("waits for in-flight idle cleanup during shutdown", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager({ disposalTimeoutMs: 1_000 }) - const session = await createSession(manager, `${upstreamBaseUrl}/base`) - const internalSession = (manager as any).sessions.get(session.sessionId) - const closeGate = deferred() - const originalClose = internalSession.app.close.bind(internalSession.app) - internalSession.app.close = async () => { - await closeGate.promise - return originalClose() - } - internalSession.lastAccessAt = Date.now() - 31 * 60_000 - const cleanup = (manager as any).cleanupExpiredSessions() as Promise - let shutdownSettled = false - const shutdown = manager.shutdown().then(() => { - shutdownSettled = true - }) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(shutdownSettled, false) - closeGate.resolve() - await cleanup - await shutdown - assert.equal((manager as any).disposals.size, 0) - }, (_req, res) => { - res.writeHead(200).end("ok") - }) - }) - - it("aborts a stalled event stream during bounded shutdown", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager({ disposalTimeoutMs: 100 }) - const session = await createSession(manager, `${upstreamBaseUrl}/base`) - await activateSession(session) - const response = await proxyFetch(`${session.proxyOrigin}/events`) - assert.equal(response.status, 200) - await Promise.race([ - manager.shutdown(), - new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)), - ]) - assert.equal((manager as any).sessions.size, 0) - assert.equal((manager as any).disposals.size, 0) - }, (req, res) => { - if (req.url === "/base/events") { - res.writeHead(200, { "content-type": "text/event-stream" }) - return void res.write("data: connected\n\n") - } - res.writeHead(200).end("ok") - }) - }) - - it("surfaces listener disposal failures", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const session = await createSession(manager, `${upstreamBaseUrl}/base`) - const internalSession = (manager as any).sessions.get(session.sessionId) - const originalClose = internalSession.app.close.bind(internalSession.app) - let failClose = true - internalSession.app.close = async () => { - await originalClose() - if (failClose) { failClose = false; throw new Error("close failed") } - } - await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/) - // A completed deletion failure predating shutdown must not poison it. - await manager.shutdown() - assert.equal((manager as any).sessions.size, 0) - }, (_req, res) => { - res.writeHead(200).end("ok") - }) - }) - - it("waits for in-flight creation and rejects sessions that cross shutdown", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const creation = manager.createSession(`${upstreamBaseUrl}/base`, false) - const shutdown = manager.shutdown() - await assert.rejects(creation, /shutting down/) - await shutdown - assert.equal((manager as any).creations.size, 0) - assert.equal((manager as any).sessions.size, 0) - await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/) - }, (_req, res) => { - res.writeHead(200).end("ok") - }) - }) - - it("coalesces shutdown, retains current disposal failures, and gives every session its own agent", async () => { - await withUpstreamServer(async (upstreamBaseUrl) => { - const manager = createSessionManager() - const verified = await manager.createSession(`${upstreamBaseUrl}/verified`, false) - const insecure = await manager.createSession(`${upstreamBaseUrl}/insecure`, true) - const sessions = (manager as any).sessions as Map - assert.ok(sessions.get(verified.sessionId).dispatcher instanceof Agent) - assert.ok(sessions.get(insecure.sessionId).dispatcher instanceof Agent) - assert.notStrictEqual(sessions.get(verified.sessionId).dispatcher, sessions.get(insecure.sessionId).dispatcher) - - const closeGate = deferred() - const originalClose = sessions.get(verified.sessionId).app.close.bind(sessions.get(verified.sessionId).app) - let failClose = true - sessions.get(verified.sessionId).app.close = async () => { - await closeGate.promise - await originalClose() - if (failClose) { failClose = false; throw new Error("current close failed") } - } - const disposal = manager.deleteSession(verified.sessionId); const first = manager.shutdown() - const concurrent = manager.shutdown() - assert.strictEqual(first, concurrent) - closeGate.resolve() - await assert.rejects(disposal, /Remote proxy disposal failed/) - await assert.rejects(first, (error: unknown) => error instanceof AggregateError && error.errors.some((cause) => - cause instanceof AggregateError && cause.errors.some((nested) => /current close failed/.test(String(nested))))) - await manager.shutdown() - assert.equal(sessions.size, 0) - }, (_req, res) => { - res.writeHead(200).end("ok") - }) - }) -}) - -function createSessionManager(options: { disposalTimeoutMs?: number } = {}) { - const manager = new RemoteProxySessionManager({ - authManager: { isLoopbackRequest: () => true } as unknown as AuthManager, - logger: createStubLogger(), httpsOptions: sharedHttpsOptions, ...options, - }) - managers.add(manager) - return manager -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) - return { promise, resolve } -} - -async function createSession(manager: RemoteProxySessionManager, baseUrl: string) { - const created = await manager.createSession(baseUrl, false) - const windowUrl = new URL(created.windowUrl) - return { - sessionId: created.sessionId, - windowUrl, - proxyOrigin: windowUrl.origin, - token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")), - } -} - -async function activateSession(session: { proxyOrigin: string; token: string }) { - const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ token: session.token }), - }) - if (!response.ok) return false - const body = (await response.json()) as { ok?: boolean } - return body.ok === true -} - -function getSetCookie(response: Awaited>): string[] { - const values = (response.headers as any).getSetCookie?.() as string[] | undefined - if (Array.isArray(values) && values.length > 0) return values - const fallback = response.headers.get("set-cookie") - return fallback ? [fallback] : [] -} - -async function proxyFetch(url: string, init?: Parameters[1]) { - return fetch(url, { dispatcher: httpsDispatcher, ...init }) -} - -async function withUpstreamServer( - callback: (baseUrl: string) => Promise, - handler: (req: IncomingMessage, res: ServerResponse) => void, -) { - const server = http.createServer(handler) - await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())) - try { - const address = server.address() - if (!address || typeof address === "string") throw new Error("Failed to resolve upstream server address") - await callback(`http://127.0.0.1:${address.port}`) - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) - } -} - -function createStubLogger(): Logger { - const logger = { info() {}, warn() {}, error() {}, child() { return logger } } - return logger as unknown as Logger -} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 19d1d8b53..bd670dee0 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -26,8 +26,7 @@ import { registerYoloRoutes } from "./routes/yolo" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" import { registerOpenCodeUpdateRoutes } from "./routes/opencode-update" -import { registerRemoteServerRoutes } from "./routes/remote-servers" -import { registerRemoteProxyRoutes } from "./routes/remote-proxy" +import { registerRemoteControlRoutes } from "./routes/remote-control" import { registerSideCarRoutes } from "./routes/sidecars" import { registerPreviewRoutes } from "./routes/previews" import { registerUsageRoutes } from "./routes/usage" @@ -42,7 +41,7 @@ import { ClientConnectionManager } from "../clients/connection-manager" import type { SideCarManager } from "../sidecars/manager" import type { PreviewManager } from "../previews/manager" import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge" -import type { RemoteProxySessionManager } from "./remote-proxy" +import type { RemoteControlManager } from "../remote-control/manager" import { createOpenCodeUpdateService } from "../opencode-update/service" import { WorktreeDeletionFence } from "../workspaces/worktree-session-evacuation" import type { NativeParent } from "../native-parent" @@ -67,7 +66,7 @@ interface HttpServerDeps { previewManager: PreviewManager authManager: AuthManager clientConnectionManager: ClientConnectionManager - remoteProxySessionManager: RemoteProxySessionManager + remoteControlManager: RemoteControlManager yoloManager: AutoAcceptManager uiStaticDir: string uiDevServerUrl?: string @@ -142,11 +141,9 @@ export function createHttpServer(deps: HttpServerDeps) { }) const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]) - const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") - const getSelfOrigins = (): Set => { const origins = new Set() - const candidates: Array = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl] + const candidates: Array = [deps.serverMeta.localUrl] for (const candidate of candidates) { if (!candidate) continue try { @@ -155,13 +152,6 @@ export function createHttpServer(deps: HttpServerDeps) { // ignore } } - for (const addr of deps.serverMeta.addresses ?? []) { - try { - origins.add(new URL(addr.remoteUrl).origin) - } catch { - // ignore - } - } return origins } @@ -188,13 +178,6 @@ export function createHttpServer(deps: HttpServerDeps) { return } - // When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access. - if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) { - cb(null, true) - return - } - - cb(null, false) }, credentials: true, @@ -222,11 +205,6 @@ export function createHttpServer(deps: HttpServerDeps) { publicPagePaths.add("/auth/token") } - const isLoopbackRemoteProxyDelete = - request.method === "DELETE" && - pathname.startsWith("/api/remote-proxy/sessions/") && - deps.authManager.isLoopbackRequest(request) - const encodedPreviewToken = pathname.match(/^\/previews\/([^/]+)(?:\/|$)/)?.[1] const hostPreviewToken = parsePreviewCapabilityHost(request.headers.host) let isPreviewCapability = false @@ -249,7 +227,7 @@ export function createHttpServer(deps: HttpServerDeps) { authManager: deps.authManager, bridgeToken: deps.automationBridgeToken, }) - if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete || isPreviewCapability || isAutomationBridge) { + if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isPreviewCapability || isAutomationBridge) { done() return } @@ -314,8 +292,7 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, workspaceManager: deps.workspaceManager, }) - registerRemoteServerRoutes(app, { logger: apiLogger }) - registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager }) + registerRemoteControlRoutes(app, { manager: deps.remoteControlManager }) registerSpeechRoutes(app, { speechService: deps.speechService }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) registerPreviewRoutes(app, { previewManager: deps.previewManager }) diff --git a/packages/server/src/server/listener-base-url.ts b/packages/server/src/server/listener-base-url.ts index 498a710a9..2a081b132 100644 --- a/packages/server/src/server/listener-base-url.ts +++ b/packages/server/src/server/listener-base-url.ts @@ -7,7 +7,6 @@ export interface StartedListenerBaseUrlInput { export interface ResolvePluginBaseUrlInput { httpStart?: StartedListenerBaseUrlInput | null httpsStart?: StartedListenerBaseUrlInput | null - remoteUrl?: string } export function resolvePluginBaseUrl(input: ResolvePluginBaseUrlInput): string { @@ -16,10 +15,6 @@ export function resolvePluginBaseUrl(input: ResolvePluginBaseUrlInput): string { return `${loopbackListener.protocol}://127.0.0.1:${loopbackListener.port}` } - if (input.remoteUrl) { - return input.remoteUrl - } - const fallbackListener = input.httpStart ?? input.httpsStart if (!fallbackListener) { throw new Error("No listeners started") diff --git a/packages/server/src/server/network-addresses.ts b/packages/server/src/server/network-addresses.ts deleted file mode 100644 index 8491fc82a..000000000 --- a/packages/server/src/server/network-addresses.ts +++ /dev/null @@ -1,128 +0,0 @@ -import os from "os" -import type { NetworkAddress } from "../api-types" - -export interface ResolvedRemoteAddresses { - all: NetworkAddress[] - userVisible: NetworkAddress[] - primaryRemoteUrl?: string -} - -export function resolveNetworkAddresses(args: { - host: string - protocol: "http" | "https" - port: number -}): NetworkAddress[] { - const { host, protocol, port } = args - const interfaces = os.networkInterfaces() - const seen = new Set() - const results: NetworkAddress[] = [] - - const addAddress = (ip: string, scope: NetworkAddress["scope"]) => { - if (!ip || ip === "0.0.0.0") return - const key = `ipv4-${ip}` - if (seen.has(key)) return - seen.add(key) - results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` }) - } - - const normalizeFamily = (value: string | number) => { - if (typeof value === "string") { - const lowered = value.toLowerCase() - if (lowered === "ipv4") { - return "ipv4" as const - } - } - if (value === 4) return "ipv4" as const - return null - } - - if (host === "0.0.0.0") { - // Enumerate system interfaces (IPv4 only) - for (const entries of Object.values(interfaces)) { - if (!entries) continue - for (const entry of entries) { - const family = normalizeFamily(entry.family) - if (!family) continue - if (!entry.address || entry.address === "0.0.0.0") continue - const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external" - addAddress(entry.address, scope) - } - } - } - - // Always include loopback address - addAddress("127.0.0.1", "loopback") - - // Include explicitly configured host if it was IPv4 - if (isIPv4Address(host) && host !== "0.0.0.0") { - const isLoopback = host.startsWith("127.") - addAddress(host, isLoopback ? "loopback" : "external") - } - - const scopeWeight: Record = { external: 0, internal: 1, loopback: 2 } - - return results.sort((a, b) => { - const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] - if (scopeDelta !== 0) return scopeDelta - - return 0 - }) -} - -export function resolveRemoteAddresses(args: { - host: string - protocol: "http" | "https" - port: number -}): ResolvedRemoteAddresses { - const all = resolveNetworkAddresses(args) - const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external")) - return { - all, - userVisible, - primaryRemoteUrl: userVisible[0]?.remoteUrl, - } -} - -function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] { - return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip)) -} - -function getUserVisiblePriority(ip: string): number { - if (isPrivateIPv4(ip)) return 0 - if (isLinkLocalIPv4(ip)) return 2 - return 1 -} - -function isLinkLocalIPv4(ip: string): boolean { - const octets = parseIPv4(ip) - if (!octets) return false - const [first, second] = octets - return first === 169 && second === 254 -} - -function isPrivateIPv4(ip: string): boolean { - const octets = parseIPv4(ip) - if (!octets) return false - const [first, second] = octets - - if (first === 10) return true - if (first === 192 && second === 168) return true - return first === 172 && second >= 16 && second <= 31 -} - -function parseIPv4(value: string): number[] | null { - if (!isIPv4Address(value)) return null - return value.split(".").map((part) => Number(part)) -} - -function isIPv4Address(value: string | undefined): value is string { - if (!value) return false - const parts = value.split(".") - if (parts.length !== 4) return false - return parts.every((part) => { - if (part.length === 0 || part.length > 3) return false - if (!/^[0-9]+$/.test(part)) return false - const num = Number(part) - return Number.isInteger(num) && num >= 0 && num <= 255 - }) -} diff --git a/packages/server/src/server/remote-proxy.ts b/packages/server/src/server/remote-proxy.ts deleted file mode 100644 index a4c47aac2..000000000 --- a/packages/server/src/server/remote-proxy.ts +++ /dev/null @@ -1,621 +0,0 @@ -import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify" -import { randomBytes, randomUUID } from "crypto" -import { Readable } from "stream" -import { pipeline } from "stream/promises" -import { Agent, fetch } from "undici" -import type { AuthManager } from "../auth/manager" -import type { Logger } from "../logger" - -const LOOPBACK_HOST = "127.0.0.1" -const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token" -const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token" -const SESSION_IDLE_TTL_MS = 30 * 60_000 -const SESSION_DISPOSAL_TIMEOUT_MS = 5_000 - -interface RemoteProxySession { - id: string - bootstrapToken: string - targetBaseUrl: URL - localBaseUrl: URL - activated: boolean - cookiePrefix: string - app: FastifyInstance - dispatcher?: Agent - abortController: AbortController - lastAccessAt: number -} - -export interface RemoteProxySessionManagerOptions { - authManager: AuthManager - logger: Logger - httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } - disposalTimeoutMs?: number -} - -export interface RemoteProxySessionCreateResult { - sessionId: string - windowUrl: string -} - -export class RemoteProxySessionManager { - private readonly sessions = new Map() - private readonly creations = new Set>() - private readonly disposals = new Set>() - private readonly sessionDisposals = new Map>() - private readonly cleanupTimer: NodeJS.Timeout - private shuttingDown = false - private shutdownPromise?: Promise - - constructor(private readonly options: RemoteProxySessionManagerOptions) { - this.cleanupTimer = setInterval(() => void this.cleanupExpiredSessions().catch((error) => - this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session")), 60_000) - this.cleanupTimer.unref() - } - - async createSession(baseUrl: string, skipTlsVerify: boolean): Promise { - if (this.shuttingDown) throw new Error("Remote proxy session manager is shutting down") - - return this.track(this.creations, this.createSessionInternal(baseUrl, skipTlsVerify)) - } - - private async createSessionInternal(baseUrl: string, skipTlsVerify: boolean): Promise { - if (!this.options.httpsOptions) { - throw new Error("Local HTTPS is required for remote proxy sessions") - } - - const targetBaseUrl = normalizeBaseUrl(baseUrl) - const sessionId = randomUUID() - const bootstrapToken = randomBytes(32).toString("base64url") - const dispatcher = new Agent(skipTlsVerify ? { connect: { rejectUnauthorized: false } } : {}) - const abortController = new AbortController() - const app = Fastify({ logger: false, https: this.options.httpsOptions, forceCloseConnections: true }) - let session: RemoteProxySession | null = null - - app.removeAllContentTypeParsers() - // Preserve raw request bodies for proxying while still letting token JSON parse from Buffer. - app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body)) - - app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => { - if (!this.options.authManager.isLoopbackRequest(request)) { - reply.code(404).send({ error: "Not found" }) - return - } - - reply.header("Cache-Control", "no-store") - reply.header("Pragma", "no-cache") - reply.header("Expires", "0") - reply.type("text/html").send(buildBootstrapPageHtml()) - }) - - app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => { - if (!this.options.authManager.isLoopbackRequest(request)) { - reply.code(404).send({ error: "Not found" }) - return - } - - if (!session) { - reply.code(503).send({ error: "Remote proxy session is unavailable" }) - return - } - - const body = parseTokenBody(request.body) - if (body.token !== session.bootstrapToken) { - reply.code(401).send({ error: "Invalid token" }) - return - } - - session.activated = true - session.lastAccessAt = Date.now() - reply.send({ ok: true }) - }) - - const handleProxyRequest = async (request: FastifyRequest, reply: FastifyReply) => { - if (!session) { - reply.code(503).send({ error: "Remote proxy session is unavailable" }) - return - } - - if (!session.activated) { - reply.code(403).send({ error: "Remote proxy session is not activated" }) - return - } - - session.lastAccessAt = Date.now() - await proxyRequest({ request, reply, session, logger: this.options.logger }) - } - app.all("/*", handleProxyRequest) - app.setNotFoundHandler(handleProxyRequest) - - const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 }) - const address = new URL(addressInfo) - const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`) - const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl) - const returnTo = buildReturnToTarget(entryUrl) - const bootstrapUrl = `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}` - - session = { - id: sessionId, - bootstrapToken, - targetBaseUrl, - localBaseUrl, - activated: false, - cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`, - app, - dispatcher, - abortController, - lastAccessAt: Date.now(), - } - - this.sessions.set(sessionId, session) - if (this.shuttingDown) { - await this.disposeSession(sessionId) - throw new Error("Remote proxy session manager is shutting down") - } - this.options.logger.info( - { sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() }, - "Created remote proxy session", - ) - - return { sessionId, windowUrl: bootstrapUrl } - } - - async deleteSession(sessionId: string): Promise { - return this.disposeSession(sessionId) - } - - shutdown(): Promise { - if (this.shutdownPromise) return this.shutdownPromise - this.shuttingDown = true - clearInterval(this.cleanupTimer) - const shutdown = this.drainShutdown() - this.shutdownPromise = shutdown - void shutdown.finally(() => { - if (this.shutdownPromise === shutdown) this.shutdownPromise = undefined - }).catch(() => undefined) - return shutdown - } - - private async drainShutdown(): Promise { - const disposals = new Set(this.disposals) - while (this.creations.size > 0) { - await Promise.allSettled([...this.creations]) - for (const disposal of this.disposals) disposals.add(disposal) - } - const pendingResults = await Promise.allSettled(disposals) - const results = await Promise.allSettled(Array.from(this.sessions.keys(), (id) => this.disposeSession(id))) - const failures = [...pendingResults, ...results] - .flatMap((result) => result.status === "rejected" ? [result.reason] : []) - if (failures.length) throw new AggregateError(failures, "Remote proxy shutdown failed") - } - - private async cleanupExpiredSessions() { - const now = Date.now() - for (const session of Array.from(this.sessions.values())) { - if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) { - continue - } - await this.disposeSession(session.id) - } - } - - private disposeSession(sessionId: string): Promise { - const pending = this.sessionDisposals.get(sessionId) - if (pending) return pending - const session = this.sessions.get(sessionId) - if (!session) return Promise.resolve(false) - - session.abortController.abort() - const disposal = this.trackDisposal(this.disposeResources(session.app, session.dispatcher).then(() => { - if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId) - this.options.logger.info({ sessionId }, "Disposed remote proxy session") - return true - })) - this.sessionDisposals.set(sessionId, disposal) - void disposal.finally(() => { - if (this.sessionDisposals.get(sessionId) === disposal) this.sessionDisposals.delete(sessionId) - }).catch(() => undefined) - return disposal - } - - private async disposeResources(app: FastifyInstance, dispatcher?: Agent): Promise { - app.server.closeAllConnections?.() - const results = await Promise.race([ - Promise.allSettled([app.close(), dispatcher?.destroy()]), - new Promise((_resolve, reject) => AbortSignal.timeout( - Math.max(1, this.options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS), - ).addEventListener( - "abort", () => reject(new Error("Remote proxy disposal timed out")), - )), - ]) - const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) - if (failures.length) throw new AggregateError(failures, "Remote proxy disposal failed") - } - - private track(operations: Set>, operation: Promise): Promise { - operations.add(operation) - void operation.finally(() => operations.delete(operation)).catch(() => undefined) - return operation - } - - private trackDisposal(operation: Promise): Promise { - return this.track(this.disposals, operation) - } -} - -function normalizeBaseUrl(input: string): URL { - const parsed = new URL(input.trim()) - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("Server URL must use http:// or https://") - } - - parsed.hash = "" - parsed.search = "" - parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/" - return parsed -} - -function buildReturnToTarget(entryUrl: URL): string { - const query = entryUrl.search ? entryUrl.search : "" - return `${entryUrl.pathname || "/"}${query}` -} - -function buildBootstrapPageHtml(): string { - return ` - - - - - CodeNomad - - - -
-

Connecting...

-

Finalizing local authentication.

-
-
- - -` -} - -function parseTokenBody(body: unknown): { token: string } { - const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined - const token = typeof value?.token === "string" ? value.token.trim() : "" - if (!token) { - throw new Error("Missing bootstrap token") - } - return { token } -} - -function normalizeJsonBody(body: unknown): unknown { - if (Buffer.isBuffer(body)) { - return JSON.parse(body.toString("utf-8")) - } - if (typeof body === "string") { - return JSON.parse(body) - } - return body -} - -function toRequestBody(body: unknown): any { - if (body == null) { - return undefined - } - if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) { - return body - } - return JSON.stringify(body) -} - -async function proxyRequest(args: { - request: FastifyRequest - reply: FastifyReply - session: RemoteProxySession - logger: Logger -}) { - const { request, reply, session, logger } = args - const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url) - const headers = filterRequestHeaders(request.headers, session) - - const init: any = { - method: request.method, - headers, - dispatcher: session.dispatcher, - signal: session.abortController.signal, - redirect: "manual", - } - - if (request.method !== "GET" && request.method !== "HEAD") { - const body = toRequestBody(request.body) - if (body !== undefined) { - init.body = body - init.duplex = "half" - } - } - - try { - const response = await fetch(upstreamUrl, init as any) - reply.code(response.status) - applyResponseHeaders(reply, response, session) - - if (!response.body || request.method === "HEAD") { - reply.send() - return - } - - reply.hijack() - reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders())) - await pipeline(Readable.fromWeb(response.body as any), reply.raw) - } catch (error) { - logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request") - if (!reply.sent) { - reply.code(502).send({ error: "Remote proxy request failed" }) - } - } -} - -function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string { - const parsed = new URL(rawUrl, "https://localhost") - const url = new URL(baseUrl.toString()) - url.pathname = rewriteRequestPath(baseUrl, parsed.pathname) - url.search = stripInternalQuery(parsed.search) - url.hash = "" - return url.toString() -} - -function rewriteRequestPath(baseUrl: URL, requestPath: string): string { - const basePath = normalizedBasePath(baseUrl) - if (basePath === "/") { - return requestPath - } - - if (requestPath === "/") { - return basePath - } - - if (pathHasBasePrefix(basePath, requestPath)) { - return requestPath - } - - return `${basePath}${requestPath}` -} - -function normalizedBasePath(baseUrl: URL): string { - return baseUrl.pathname || "/" -} - -function pathHasBasePrefix(basePath: string, requestPath: string): boolean { - return requestPath === basePath || requestPath.startsWith(`${basePath}/`) -} - -function stripInternalQuery(search: string): string { - if (!search || search === "?") { - return "" - } - return search -} - -function filterRequestHeaders( - headers: FastifyRequest["headers"], - session: RemoteProxySession, -): Record { - const next: Record = {} - for (const [key, value] of Object.entries(headers ?? {})) { - if (!value) continue - const lower = key.toLowerCase() - if ( - isHopByHopHeader(lower) || - lower === "host" || - lower === "content-length" || - lower === "accept-encoding" - ) { - continue - } - if (lower === "origin") { - next[key] = session.targetBaseUrl.origin - continue - } - if (lower === "referer") { - const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl) - if (rewritten) { - next[key] = rewritten - } - continue - } - if (lower === "cookie") { - const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix) - if (rewritten) { - next[key] = rewritten - } - continue - } - next[key] = Array.isArray(value) ? value.join(",") : value - } - - next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname - if (!next.origin) { - next.origin = session.targetBaseUrl.origin - } - return next -} - -function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null { - if (!referer) { - return null - } - - try { - const parsed = new URL(referer) - const rewritten = new URL(targetBaseUrl.toString()) - rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname) - rewritten.search = parsed.search - rewritten.hash = parsed.hash - return rewritten.toString() - } catch { - return null - } -} - -function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) { - const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined - if (Array.isArray(setCookie)) { - for (const cookie of setCookie) { - reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix)) - } - } - - response.headers.forEach((value: string, key: string) => { - const lower = key.toLowerCase() - if ( - isHopByHopHeader(lower) || - lower === "set-cookie" || - lower === "content-length" || - lower === "content-encoding" - ) { - return - } - - if (lower === "location") { - reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl)) - return - } - - reply.header(key, value) - }) -} - -function toOutgoingHeaders(headers: ReturnType): Record { - const next: Record = {} - for (const [key, value] of Object.entries(headers)) { - if (value === undefined) { - continue - } - next[key] = Array.isArray(value) ? value.map(String) : String(value) - } - return next -} - -function rewriteSetCookie(cookie: string, cookiePrefix: string): string { - const parts = cookie.split(";").map((part) => part.trim()) - const first = parts.shift() ?? "" - const separator = first.indexOf("=") - if (separator <= 0) { - return cookie - } - - const name = first.slice(0, separator).trim() - const value = first.slice(separator + 1) - const rewritten = [`${cookiePrefix}${name}=${value}`] - for (const part of parts) { - if (part.slice(0, 7).toLowerCase().startsWith("domain=")) { - continue - } - rewritten.push(part) - } - return rewritten.join("; ") -} - -function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string { - const next: string[] = [] - for (const rawPart of cookieHeader.split(";")) { - const part = rawPart.trim() - if (!part) continue - const separator = part.indexOf("=") - if (separator <= 0) continue - const name = part.slice(0, separator).trim() - const value = part.slice(separator + 1) - if (!name.startsWith(cookiePrefix)) { - continue - } - next.push(`${name.slice(cookiePrefix.length)}=${value}`) - } - return next.join("; ") -} - -function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string { - try { - const parsed = new URL(location, targetBaseUrl) - if (parsed.origin !== targetBaseUrl.origin) { - return location - } - - const rewritten = new URL(localBaseUrl.toString()) - rewritten.pathname = parsed.pathname - rewritten.search = parsed.search - rewritten.hash = parsed.hash - return rewritten.toString() - } catch { - return location - } -} - -function isHopByHopHeader(name: string): boolean { - return new Set([ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", - ]).has(name) -} diff --git a/packages/server/src/server/routes/meta.ts b/packages/server/src/server/routes/meta.ts index 65adda5f4..4c0ad95b0 100644 --- a/packages/server/src/server/routes/meta.ts +++ b/packages/server/src/server/routes/meta.ts @@ -1,24 +1,19 @@ import { FastifyInstance } from "fastify" import { ServerMeta } from "../../api-types" - + interface RouteDeps { serverMeta: ServerMeta } - export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta)) } - function buildMetaResponse(meta: ServerMeta): ServerMeta { const localPort = resolveLocalPort(meta) - const remote = resolveRemote(meta) return { ...meta, localPort, - remotePort: remote?.port, - listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local", } } @@ -34,23 +29,3 @@ function resolveLocalPort(meta: ServerMeta): number { return 0 } } - -function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null { - if (!meta.remoteUrl) { - return null - } - try { - const parsed = new URL(meta.remoteUrl) - const protocol = parsed.protocol === "https:" ? "https" : "http" - const port = Number(parsed.port) - return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 } - } catch { - return null - } -} - -function isLoopbackHost(host: string): boolean { - return host === "127.0.0.1" || host === "::1" || host.startsWith("127.") -} - -// NetworkAddress shape is resolved in ../network-addresses diff --git a/packages/server/src/server/routes/remote-control.test.ts b/packages/server/src/server/routes/remote-control.test.ts new file mode 100644 index 000000000..c5f91f346 --- /dev/null +++ b/packages/server/src/server/routes/remote-control.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" +import type { RemoteControlManager } from "../../remote-control/manager" +import { registerRemoteControlRoutes } from "./remote-control" + +function manager() { + return { + status: () => ({ manageable: true, enabled: false, state: "stopped", hostId: "a".repeat(32), relayUrl: "https://relay.example", remoteUrl: "https://host.relay.example", pairedDevices: 0 }), + start: async () => { throw new Error("not used") }, + stop: () => ({ manageable: true, enabled: false, state: "stopped", hostId: "a".repeat(32), relayUrl: "https://relay.example", remoteUrl: "https://host.relay.example", pairedDevices: 0 }), + createPairing: async () => { throw new Error("not used") }, + devices: async () => [], + revokeDevice: async () => undefined, + } as unknown as RemoteControlManager +} + +test("Remote Control status is readable remotely but management remains local-only", async () => { + const app = Fastify() + registerRemoteControlRoutes(app, { manager: manager() }) + const local = await app.inject({ method: "GET", url: "/api/remote-control/status" }) + assert.equal(local.statusCode, 200) + + const relayed = await app.inject({ + method: "GET", + url: "/api/remote-control/status", + headers: { "x-codenomad-remote-control": "1" }, + }) + assert.equal(relayed.statusCode, 200) + assert.equal(relayed.json().manageable, false) + const blocked = await app.inject({ + method: "DELETE", + url: "/api/remote-control", + headers: { "x-codenomad-remote-control": "1" }, + }) + assert.equal(blocked.statusCode, 403) + await app.close() +}) + +test("device revocation validates UUIDs before reaching the relay", async () => { + const app = Fastify() + registerRemoteControlRoutes(app, { manager: manager() }) + const response = await app.inject({ method: "DELETE", url: "/api/remote-control/devices/not-a-uuid" }) + assert.equal(response.statusCode, 400) + await app.close() +}) diff --git a/packages/server/src/server/routes/remote-control.ts b/packages/server/src/server/routes/remote-control.ts new file mode 100644 index 000000000..62b157e1f --- /dev/null +++ b/packages/server/src/server/routes/remote-control.ts @@ -0,0 +1,79 @@ +import type { FastifyInstance, FastifyRequest } from "fastify" +import { z } from "zod" +import type { RemoteControlManager } from "../../remote-control/manager" + +interface RouteDeps { + manager: RemoteControlManager +} + +const DeviceParams = z.object({ id: z.string().uuid() }) + +export function registerRemoteControlRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/remote-control/status", async (request, reply) => { + const status = deps.manager.status() + return isRemoteControlRequest(request) ? { ...status, manageable: false } : status + }) + + app.post("/api/remote-control/start", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + try { + return await deps.manager.start() + } catch (error) { + reply.code(502) + return { error: error instanceof Error ? error.message : "Remote Control failed to start" } + } + }) + + app.post("/api/remote-control/pairings", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + try { + return await deps.manager.createPairing() + } catch (error) { + reply.code(502) + return { error: error instanceof Error ? error.message : "Pairing link creation failed" } + } + }) + + app.delete("/api/remote-control", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + return deps.manager.stop() + }) + + app.get("/api/remote-control/devices", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + try { + return { devices: await deps.manager.devices() } + } catch (error) { + reply.code(502) + return { error: error instanceof Error ? error.message : "Could not load remote devices" } + } + }) + + app.delete("/api/remote-control/devices/:id", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + const parsed = DeviceParams.safeParse(request.params) + if (!parsed.success) { + reply.code(400) + return { error: parsed.error.message } + } + try { + await deps.manager.revokeDevice(parsed.data.id) + reply.code(204).send() + } catch (error) { + reply.code(502) + return { error: error instanceof Error ? error.message : "Could not revoke remote device" } + } + }) +} + +function requireLocalControl(request: FastifyRequest, reply: { code: (status: number) => { send: (body: unknown) => unknown } }): boolean { + if (isRemoteControlRequest(request)) { + reply.code(403).send({ error: "Remote Control settings are available on the host only" }) + return false + } + return true +} + +function isRemoteControlRequest(request: FastifyRequest): boolean { + return request.headers["x-codenomad-remote-control"] === "1" +} diff --git a/packages/server/src/server/routes/remote-proxy.ts b/packages/server/src/server/routes/remote-proxy.ts deleted file mode 100644 index a26cdc107..000000000 --- a/packages/server/src/server/routes/remote-proxy.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { FastifyInstance } from "fastify" -import { z } from "zod" -import type { RemoteProxySessionCreateResponse } from "../../api-types" -import { isLoopbackAddress } from "../../auth/http-auth" -import type { Logger } from "../../logger" -import type { RemoteProxySessionManager } from "../remote-proxy" - -interface RouteDeps { - logger: Logger - sessionManager: RemoteProxySessionManager -} - -const CreateSessionSchema = z.object({ - baseUrl: z.string().min(1), - skipTlsVerify: z.boolean().optional(), -}) - -const SessionParamsSchema = z.object({ - id: z.string().uuid(), -}) - -export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) { - app.post("/api/remote-proxy/sessions", async (request, reply): Promise => { - try { - const body = CreateSessionSchema.parse(request.body ?? {}) - return await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify)) - } catch (error) { - deps.logger.warn({ err: error }, "Failed to create remote proxy session") - reply.code(400) - return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" } - } - }) - - app.delete("/api/remote-proxy/sessions/:id", async (request, reply): Promise<{ ok: boolean } | { error: string }> => { - if (!isLoopbackAddress(request.socket.remoteAddress)) { - reply.code(404) - return { error: "Not found" } - } - - try { - const params = SessionParamsSchema.parse(request.params ?? {}) - const deleted = await deps.sessionManager.deleteSession(params.id) - if (!deleted) { - reply.code(404) - return { error: "Remote proxy session not found" } - } - return { ok: true } - } catch (error) { - deps.logger.warn({ err: error }, "Failed to delete remote proxy session") - reply.code(400) - return { error: error instanceof Error ? error.message : "Failed to delete remote proxy session" } - } - }) -} diff --git a/packages/server/src/server/routes/remote-servers.ts b/packages/server/src/server/routes/remote-servers.ts deleted file mode 100644 index 86c005694..000000000 --- a/packages/server/src/server/routes/remote-servers.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Agent, fetch } from "undici" -import type { FastifyInstance } from "fastify" -import { z } from "zod" -import type { Logger } from "../../logger" -import type { RemoteServerProbeResponse } from "../../api-types" - -interface RouteDeps { - logger: Logger -} - -const ProbeSchema = z.object({ - baseUrl: z.string().min(1), - skipTlsVerify: z.boolean().optional(), -}) - -const PROBE_TIMEOUT_MS = 8_000 - -export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) { - app.post("/api/remote-servers/probe", async (request, reply) => { - try { - const body = ProbeSchema.parse(request.body ?? {}) - return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify)) - } catch (error) { - deps.logger.warn({ err: error }, "Failed to probe remote server") - reply.code(400) - return { error: error instanceof Error ? error.message : "Invalid request" } - } - }) -} - -async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise { - const normalizedUrl = normalizeBaseUrl(baseUrl) - const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`) - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) - const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined - - try { - const response = await fetch(probeUrl, { - method: "GET", - dispatcher, - signal: controller.signal, - headers: { - Accept: "application/json", - }, - }) - - if (!response.ok) { - return { - ok: false, - reachable: true, - normalizedUrl, - skipTlsVerify, - requiresAuth: false, - authenticated: false, - error: `Remote server returned HTTP ${response.status}`, - errorCode: "http_error", - } - } - - const payload = (await response.json()) as { authenticated?: unknown } - if (typeof payload?.authenticated !== "boolean") { - return { - ok: false, - reachable: true, - normalizedUrl, - skipTlsVerify, - requiresAuth: false, - authenticated: false, - error: "Remote server did not return a valid CodeNomad auth response", - errorCode: "invalid_server", - } - } - - return { - ok: true, - reachable: true, - normalizedUrl, - skipTlsVerify, - requiresAuth: !payload.authenticated, - authenticated: payload.authenticated, - } - } catch (error) { - const message = describeProbeError(error) - return { - ok: false, - reachable: false, - normalizedUrl, - skipTlsVerify, - requiresAuth: false, - authenticated: false, - error: message.message, - errorCode: message.code, - } - } finally { - clearTimeout(timeout) - await dispatcher?.close().catch(() => {}) - } -} - -function normalizeBaseUrl(input: string): string { - const parsed = new URL(input.trim()) - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("Server URL must use http:// or https://") - } - - parsed.hash = "" - parsed.search = "" - parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/" - const value = parsed.toString() - return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "") -} - -function describeProbeError(error: unknown): { code: string; message: string } { - const chain = unwrapErrorChain(error) - const detailed = - chain.find((entry) => { - const code = (entry?.code ?? "").toString() - return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE" - }) ?? chain[0] - - const code = (detailed?.code ?? "").toString() - const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim() - - if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") { - return { - code: "tls_error", - message: "Certificate check failed while connecting to the remote server.", - } - } - - return { - code: - code === "ERR_INVALID_URL" - ? "invalid_url" - : code === "ECONNREFUSED" - ? "connection_refused" - : code === "ENOTFOUND" - ? "dns_error" - : code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR" - ? "timeout" - : code - ? code.toLowerCase() - : "probe_failed", - message: exactMessage || "Failed to connect to the remote server.", - } -} - -function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> { - const results: Array<{ code?: unknown; message?: string }> = [] - let current: unknown = error - const seen = new Set() - - while (current && typeof current === "object" && !seen.has(current)) { - seen.add(current) - const entry = current as { code?: unknown; message?: string; cause?: unknown } - results.push({ code: entry.code, message: entry.message }) - current = entry.cause - } - - if (results.length === 0 && error instanceof Error) { - results.push({ message: error.message }) - } - - return results -} diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts index 5220805c2..ec9f9a9b7 100644 --- a/packages/server/src/settings/migrate.ts +++ b/packages/server/src/settings/migrate.ts @@ -103,10 +103,6 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co if (isPlainObject(envVars)) { serverConfig.environmentVariables = { ...envVars } } - const listeningMode = preferences.listeningMode - if (typeof listeningMode === "string") { - serverConfig.listeningMode = listeningMode - } const logLevel = preferences.logLevel if (typeof logLevel === "string") { serverConfig.logLevel = logLevel @@ -138,7 +134,6 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co // Remaining preferences are treated as stable UI settings. const moved = new Set([ "environmentVariables", - "listeningMode", "logLevel", "lastUsedBinary", "modelRecents", diff --git a/packages/server/src/shutdown.test.ts b/packages/server/src/shutdown.test.ts index 446e77918..84dc96f52 100644 --- a/packages/server/src/shutdown.test.ts +++ b/packages/server/src/shutdown.test.ts @@ -11,7 +11,7 @@ import { const logger = { info() {}, warn() {}, error() {} } const operations = (overrides: Partial = {}): ServerShutdownOperations => ({ stopInstanceEventBridge() {}, stopSidecars() {}, stopClientConnections() {}, - stopRemoteProxySessions() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {}, + stopRemoteControl() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {}, ...overrides, }) @@ -20,11 +20,11 @@ describe("server shutdown orchestration", () => { const calls: string[] = [] let attempts = 0 await orchestrateServerShutdown(operations({ - stopRemoteProxySessions: () => { calls.push("remote-proxy") }, + stopRemoteControl: () => { calls.push("remote-control") }, stopWorkspaces: () => { calls.push(`workspaces-${++attempts}`); if (attempts === 1) throw new Error("still alive") }, stopHttpServers: () => { calls.push("http") }, }), logger) - assert.deepEqual(calls, ["workspaces-1", "remote-proxy", "workspaces-2", "http"]) + assert.deepEqual(calls, ["workspaces-1", "remote-control", "workspaces-2", "http"]) }) it("closes remaining resources and aggregates the concrete current error", async () => { @@ -50,7 +50,7 @@ describe("server shutdown orchestration", () => { const preliminary = new Promise((resolve) => { releasePreliminary = resolve }) let workspaceStarted = false const shutdown = orchestrateServerShutdown(operations({ - stopRemoteProxySessions: () => preliminary, + stopRemoteControl: () => preliminary, stopWorkspaces: () => { workspaceStarted = true }, }), logger) diff --git a/packages/server/src/shutdown.ts b/packages/server/src/shutdown.ts index 3324aa255..7fdac26e3 100644 --- a/packages/server/src/shutdown.ts +++ b/packages/server/src/shutdown.ts @@ -6,7 +6,7 @@ export const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete" export const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete" export type ServerShutdownOperations = Record< - "stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteProxySessions" | "stopWorkspaces" | + "stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteControl" | "stopWorkspaces" | "stopHttpServers" | "stopReleaseMonitor", ShutdownOperation > @@ -92,7 +92,7 @@ export async function orchestrateServerShutdown( await Promise.all([ settle([ ["stopInstanceEventBridge", operations.stopInstanceEventBridge], ["stopSidecars", operations.stopSidecars], - ["stopClientConnections", operations.stopClientConnections], ["stopRemoteProxySessions", operations.stopRemoteProxySessions], + ["stopClientConnections", operations.stopClientConnections], ["stopRemoteControl", operations.stopRemoteControl], ]), workspaceShutdown, ]) diff --git a/packages/tauri-app/src-tauri/build.rs b/packages/tauri-app/src-tauri/build.rs index 888523d30..6697ac9e3 100644 --- a/packages/tauri-app/src-tauri/build.rs +++ b/packages/tauri-app/src-tauri/build.rs @@ -40,7 +40,6 @@ fn main() { "cli_restart", "wake_lock_start", "wake_lock_stop", - "needs_local_certificate_install", "open_preferences_window", "preferences_window_ready", "preferences_get_request", @@ -48,7 +47,6 @@ fn main() { "preferences_resolve_transition", "window_control", "popup_titlebar_menu", - "open_remote_window", "client_state_claim_access", "client_state_load", "client_state_save", diff --git a/packages/tauri-app/src-tauri/capabilities/main-window.json b/packages/tauri-app/src-tauri/capabilities/main-window.json index dae3a106b..fbf76fcbb 100644 --- a/packages/tauri-app/src-tauri/capabilities/main-window.json +++ b/packages/tauri-app/src-tauri/capabilities/main-window.json @@ -29,9 +29,7 @@ "allow-cli-restart", "allow-wake-lock-start", "allow-wake-lock-stop", - "allow-needs-local-certificate-install", "allow-open-preferences-window", - "allow-open-remote-window", "allow-client-state-claim-access", "allow-client-state-load", "allow-client-state-save", diff --git a/packages/tauri-app/src-tauri/capabilities/preferences-window.json b/packages/tauri-app/src-tauri/capabilities/preferences-window.json index 24ec40b97..b7d2fa9b0 100644 --- a/packages/tauri-app/src-tauri/capabilities/preferences-window.json +++ b/packages/tauri-app/src-tauri/capabilities/preferences-window.json @@ -29,8 +29,6 @@ "notification:allow-notify", "notification:allow-show", "allow-cli-get-status", - "allow-cli-restart", - "allow-needs-local-certificate-install", - "allow-open-remote-window" + "allow-cli-restart" ] } diff --git a/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json deleted file mode 100644 index 210c8d88b..000000000 --- a/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/capabilities.json", - "identifier": "remote-window-notifications", - "description": "Grant remote CodeNomad windows access only to native OS notifications.", - "local": false, - "remote": { - "urls": ["http://*:*", "https://*:*"] - }, - "windows": ["remote-*"], - "permissions": [ - "notification:allow-is-permission-granted", - "notification:allow-request-permission", - "notification:allow-notify" - ] -} diff --git a/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json b/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json index 79fd3a9c9..13b08943b 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json +++ b/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-cli-get-status":{"identifier":"allow-cli-get-status","description":"Enables the cli_get_status command without any pre-configured scope.","commands":{"allow":["cli_get_status"],"deny":[]}},"allow-cli-restart":{"identifier":"allow-cli-restart","description":"Enables the cli_restart command without any pre-configured scope.","commands":{"allow":["cli_restart"],"deny":[]}},"allow-client-state-claim-access":{"identifier":"allow-client-state-claim-access","description":"Enables the client_state_claim_access command without any pre-configured scope.","commands":{"allow":["client_state_claim_access"],"deny":[]}},"allow-client-state-clear":{"identifier":"allow-client-state-clear","description":"Enables the client_state_clear command without any pre-configured scope.","commands":{"allow":["client_state_clear"],"deny":[]}},"allow-client-state-commit-partitions":{"identifier":"allow-client-state-commit-partitions","description":"Enables the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":["client_state_commit_partitions"],"deny":[]}},"allow-client-state-load":{"identifier":"allow-client-state-load","description":"Enables the client_state_load command without any pre-configured scope.","commands":{"allow":["client_state_load"],"deny":[]}},"allow-client-state-load-partition":{"identifier":"allow-client-state-load-partition","description":"Enables the client_state_load_partition command without any pre-configured scope.","commands":{"allow":["client_state_load_partition"],"deny":[]}},"allow-client-state-navigation-flushed":{"identifier":"allow-client-state-navigation-flushed","description":"Enables the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":["client_state_navigation_flushed"],"deny":[]}},"allow-client-state-renderer-flushed":{"identifier":"allow-client-state-renderer-flushed","description":"Enables the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":["client_state_renderer_flushed"],"deny":[]}},"allow-client-state-save":{"identifier":"allow-client-state-save","description":"Enables the client_state_save command without any pre-configured scope.","commands":{"allow":["client_state_save"],"deny":[]}},"allow-client-state-set-restore-enabled":{"identifier":"allow-client-state-set-restore-enabled","description":"Enables the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":["client_state_set_restore_enabled"],"deny":[]}},"allow-desktop-launch-acknowledge-folder":{"identifier":"allow-desktop-launch-acknowledge-folder","description":"Enables the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_acknowledge_folder"],"deny":[]}},"allow-desktop-launch-next-folder":{"identifier":"allow-desktop-launch-next-folder","description":"Enables the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_next_folder"],"deny":[]}},"allow-desktop-launch-ready":{"identifier":"allow-desktop-launch-ready","description":"Enables the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":["desktop_launch_ready"],"deny":[]}},"allow-developer-mode-get":{"identifier":"allow-developer-mode-get","description":"Enables the developer_mode_get command without any pre-configured scope.","commands":{"allow":["developer_mode_get"],"deny":[]}},"allow-developer-mode-set":{"identifier":"allow-developer-mode-set","description":"Enables the developer_mode_set command without any pre-configured scope.","commands":{"allow":["developer_mode_set"],"deny":[]}},"allow-install-stable-update":{"identifier":"allow-install-stable-update","description":"Enables the install_stable_update command without any pre-configured scope.","commands":{"allow":["install_stable_update"],"deny":[]}},"allow-needs-local-certificate-install":{"identifier":"allow-needs-local-certificate-install","description":"Enables the needs_local_certificate_install command without any pre-configured scope.","commands":{"allow":["needs_local_certificate_install"],"deny":[]}},"allow-open-preferences-window":{"identifier":"allow-open-preferences-window","description":"Enables the open_preferences_window command without any pre-configured scope.","commands":{"allow":["open_preferences_window"],"deny":[]}},"allow-open-remote-window":{"identifier":"allow-open-remote-window","description":"Enables the open_remote_window command without any pre-configured scope.","commands":{"allow":["open_remote_window"],"deny":[]}},"allow-open-workspace-target":{"identifier":"allow-open-workspace-target","description":"Enables the open_workspace_target command without any pre-configured scope.","commands":{"allow":["open_workspace_target"],"deny":[]}},"allow-popup-titlebar-menu":{"identifier":"allow-popup-titlebar-menu","description":"Enables the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":["popup_titlebar_menu"],"deny":[]}},"allow-preferences-accept-request":{"identifier":"allow-preferences-accept-request","description":"Enables the preferences_accept_request command without any pre-configured scope.","commands":{"allow":["preferences_accept_request"],"deny":[]}},"allow-preferences-get-request":{"identifier":"allow-preferences-get-request","description":"Enables the preferences_get_request command without any pre-configured scope.","commands":{"allow":["preferences_get_request"],"deny":[]}},"allow-preferences-resolve-transition":{"identifier":"allow-preferences-resolve-transition","description":"Enables the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":["preferences_resolve_transition"],"deny":[]}},"allow-preferences-window-ready":{"identifier":"allow-preferences-window-ready","description":"Enables the preferences_window_ready command without any pre-configured scope.","commands":{"allow":["preferences_window_ready"],"deny":[]}},"allow-set-workspace-menu-enabled":{"identifier":"allow-set-workspace-menu-enabled","description":"Enables the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":["set_workspace_menu_enabled"],"deny":[]}},"allow-wake-lock-start":{"identifier":"allow-wake-lock-start","description":"Enables the wake_lock_start command without any pre-configured scope.","commands":{"allow":["wake_lock_start"],"deny":[]}},"allow-wake-lock-stop":{"identifier":"allow-wake-lock-stop","description":"Enables the wake_lock_stop command without any pre-configured scope.","commands":{"allow":["wake_lock_stop"],"deny":[]}},"allow-window-control":{"identifier":"allow-window-control","description":"Enables the window_control command without any pre-configured scope.","commands":{"allow":["window_control"],"deny":[]}},"deny-cli-get-status":{"identifier":"deny-cli-get-status","description":"Denies the cli_get_status command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_get_status"]}},"deny-cli-restart":{"identifier":"deny-cli-restart","description":"Denies the cli_restart command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_restart"]}},"deny-client-state-claim-access":{"identifier":"deny-client-state-claim-access","description":"Denies the client_state_claim_access command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_claim_access"]}},"deny-client-state-clear":{"identifier":"deny-client-state-clear","description":"Denies the client_state_clear command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_clear"]}},"deny-client-state-commit-partitions":{"identifier":"deny-client-state-commit-partitions","description":"Denies the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_commit_partitions"]}},"deny-client-state-load":{"identifier":"deny-client-state-load","description":"Denies the client_state_load command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load"]}},"deny-client-state-load-partition":{"identifier":"deny-client-state-load-partition","description":"Denies the client_state_load_partition command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load_partition"]}},"deny-client-state-navigation-flushed":{"identifier":"deny-client-state-navigation-flushed","description":"Denies the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_navigation_flushed"]}},"deny-client-state-renderer-flushed":{"identifier":"deny-client-state-renderer-flushed","description":"Denies the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_renderer_flushed"]}},"deny-client-state-save":{"identifier":"deny-client-state-save","description":"Denies the client_state_save command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_save"]}},"deny-client-state-set-restore-enabled":{"identifier":"deny-client-state-set-restore-enabled","description":"Denies the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_set_restore_enabled"]}},"deny-desktop-launch-acknowledge-folder":{"identifier":"deny-desktop-launch-acknowledge-folder","description":"Denies the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_acknowledge_folder"]}},"deny-desktop-launch-next-folder":{"identifier":"deny-desktop-launch-next-folder","description":"Denies the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_next_folder"]}},"deny-desktop-launch-ready":{"identifier":"deny-desktop-launch-ready","description":"Denies the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_ready"]}},"deny-developer-mode-get":{"identifier":"deny-developer-mode-get","description":"Denies the developer_mode_get command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_mode_get"]}},"deny-developer-mode-set":{"identifier":"deny-developer-mode-set","description":"Denies the developer_mode_set command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_mode_set"]}},"deny-install-stable-update":{"identifier":"deny-install-stable-update","description":"Denies the install_stable_update command without any pre-configured scope.","commands":{"allow":[],"deny":["install_stable_update"]}},"deny-needs-local-certificate-install":{"identifier":"deny-needs-local-certificate-install","description":"Denies the needs_local_certificate_install command without any pre-configured scope.","commands":{"allow":[],"deny":["needs_local_certificate_install"]}},"deny-open-preferences-window":{"identifier":"deny-open-preferences-window","description":"Denies the open_preferences_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_preferences_window"]}},"deny-open-remote-window":{"identifier":"deny-open-remote-window","description":"Denies the open_remote_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_remote_window"]}},"deny-open-workspace-target":{"identifier":"deny-open-workspace-target","description":"Denies the open_workspace_target command without any pre-configured scope.","commands":{"allow":[],"deny":["open_workspace_target"]}},"deny-popup-titlebar-menu":{"identifier":"deny-popup-titlebar-menu","description":"Denies the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["popup_titlebar_menu"]}},"deny-preferences-accept-request":{"identifier":"deny-preferences-accept-request","description":"Denies the preferences_accept_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_accept_request"]}},"deny-preferences-get-request":{"identifier":"deny-preferences-get-request","description":"Denies the preferences_get_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_get_request"]}},"deny-preferences-resolve-transition":{"identifier":"deny-preferences-resolve-transition","description":"Denies the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_resolve_transition"]}},"deny-preferences-window-ready":{"identifier":"deny-preferences-window-ready","description":"Denies the preferences_window_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_window_ready"]}},"deny-set-workspace-menu-enabled":{"identifier":"deny-set-workspace-menu-enabled","description":"Denies the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_workspace_menu_enabled"]}},"deny-wake-lock-start":{"identifier":"deny-wake-lock-start","description":"Denies the wake_lock_start command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_start"]}},"deny-wake-lock-stop":{"identifier":"deny-wake-lock-stop","description":"Denies the wake_lock_stop command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_stop"]}},"deny-window-control":{"identifier":"deny-window-control","description":"Denies the window_control command without any pre-configured scope.","commands":{"allow":[],"deny":["window_control"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-cli-get-status":{"identifier":"allow-cli-get-status","description":"Enables the cli_get_status command without any pre-configured scope.","commands":{"allow":["cli_get_status"],"deny":[]}},"allow-cli-restart":{"identifier":"allow-cli-restart","description":"Enables the cli_restart command without any pre-configured scope.","commands":{"allow":["cli_restart"],"deny":[]}},"allow-client-state-claim-access":{"identifier":"allow-client-state-claim-access","description":"Enables the client_state_claim_access command without any pre-configured scope.","commands":{"allow":["client_state_claim_access"],"deny":[]}},"allow-client-state-clear":{"identifier":"allow-client-state-clear","description":"Enables the client_state_clear command without any pre-configured scope.","commands":{"allow":["client_state_clear"],"deny":[]}},"allow-client-state-commit-partitions":{"identifier":"allow-client-state-commit-partitions","description":"Enables the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":["client_state_commit_partitions"],"deny":[]}},"allow-client-state-load":{"identifier":"allow-client-state-load","description":"Enables the client_state_load command without any pre-configured scope.","commands":{"allow":["client_state_load"],"deny":[]}},"allow-client-state-load-partition":{"identifier":"allow-client-state-load-partition","description":"Enables the client_state_load_partition command without any pre-configured scope.","commands":{"allow":["client_state_load_partition"],"deny":[]}},"allow-client-state-navigation-flushed":{"identifier":"allow-client-state-navigation-flushed","description":"Enables the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":["client_state_navigation_flushed"],"deny":[]}},"allow-client-state-renderer-flushed":{"identifier":"allow-client-state-renderer-flushed","description":"Enables the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":["client_state_renderer_flushed"],"deny":[]}},"allow-client-state-save":{"identifier":"allow-client-state-save","description":"Enables the client_state_save command without any pre-configured scope.","commands":{"allow":["client_state_save"],"deny":[]}},"allow-client-state-set-restore-enabled":{"identifier":"allow-client-state-set-restore-enabled","description":"Enables the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":["client_state_set_restore_enabled"],"deny":[]}},"allow-desktop-launch-acknowledge-folder":{"identifier":"allow-desktop-launch-acknowledge-folder","description":"Enables the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_acknowledge_folder"],"deny":[]}},"allow-desktop-launch-next-folder":{"identifier":"allow-desktop-launch-next-folder","description":"Enables the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_next_folder"],"deny":[]}},"allow-desktop-launch-ready":{"identifier":"allow-desktop-launch-ready","description":"Enables the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":["desktop_launch_ready"],"deny":[]}},"allow-developer-mode-get":{"identifier":"allow-developer-mode-get","description":"Enables the developer_mode_get command without any pre-configured scope.","commands":{"allow":["developer_mode_get"],"deny":[]}},"allow-developer-mode-set":{"identifier":"allow-developer-mode-set","description":"Enables the developer_mode_set command without any pre-configured scope.","commands":{"allow":["developer_mode_set"],"deny":[]}},"allow-install-stable-update":{"identifier":"allow-install-stable-update","description":"Enables the install_stable_update command without any pre-configured scope.","commands":{"allow":["install_stable_update"],"deny":[]}},"allow-open-preferences-window":{"identifier":"allow-open-preferences-window","description":"Enables the open_preferences_window command without any pre-configured scope.","commands":{"allow":["open_preferences_window"],"deny":[]}},"allow-open-workspace-target":{"identifier":"allow-open-workspace-target","description":"Enables the open_workspace_target command without any pre-configured scope.","commands":{"allow":["open_workspace_target"],"deny":[]}},"allow-popup-titlebar-menu":{"identifier":"allow-popup-titlebar-menu","description":"Enables the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":["popup_titlebar_menu"],"deny":[]}},"allow-preferences-accept-request":{"identifier":"allow-preferences-accept-request","description":"Enables the preferences_accept_request command without any pre-configured scope.","commands":{"allow":["preferences_accept_request"],"deny":[]}},"allow-preferences-get-request":{"identifier":"allow-preferences-get-request","description":"Enables the preferences_get_request command without any pre-configured scope.","commands":{"allow":["preferences_get_request"],"deny":[]}},"allow-preferences-resolve-transition":{"identifier":"allow-preferences-resolve-transition","description":"Enables the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":["preferences_resolve_transition"],"deny":[]}},"allow-preferences-window-ready":{"identifier":"allow-preferences-window-ready","description":"Enables the preferences_window_ready command without any pre-configured scope.","commands":{"allow":["preferences_window_ready"],"deny":[]}},"allow-set-workspace-menu-enabled":{"identifier":"allow-set-workspace-menu-enabled","description":"Enables the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":["set_workspace_menu_enabled"],"deny":[]}},"allow-wake-lock-start":{"identifier":"allow-wake-lock-start","description":"Enables the wake_lock_start command without any pre-configured scope.","commands":{"allow":["wake_lock_start"],"deny":[]}},"allow-wake-lock-stop":{"identifier":"allow-wake-lock-stop","description":"Enables the wake_lock_stop command without any pre-configured scope.","commands":{"allow":["wake_lock_stop"],"deny":[]}},"allow-window-control":{"identifier":"allow-window-control","description":"Enables the window_control command without any pre-configured scope.","commands":{"allow":["window_control"],"deny":[]}},"deny-cli-get-status":{"identifier":"deny-cli-get-status","description":"Denies the cli_get_status command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_get_status"]}},"deny-cli-restart":{"identifier":"deny-cli-restart","description":"Denies the cli_restart command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_restart"]}},"deny-client-state-claim-access":{"identifier":"deny-client-state-claim-access","description":"Denies the client_state_claim_access command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_claim_access"]}},"deny-client-state-clear":{"identifier":"deny-client-state-clear","description":"Denies the client_state_clear command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_clear"]}},"deny-client-state-commit-partitions":{"identifier":"deny-client-state-commit-partitions","description":"Denies the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_commit_partitions"]}},"deny-client-state-load":{"identifier":"deny-client-state-load","description":"Denies the client_state_load command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load"]}},"deny-client-state-load-partition":{"identifier":"deny-client-state-load-partition","description":"Denies the client_state_load_partition command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load_partition"]}},"deny-client-state-navigation-flushed":{"identifier":"deny-client-state-navigation-flushed","description":"Denies the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_navigation_flushed"]}},"deny-client-state-renderer-flushed":{"identifier":"deny-client-state-renderer-flushed","description":"Denies the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_renderer_flushed"]}},"deny-client-state-save":{"identifier":"deny-client-state-save","description":"Denies the client_state_save command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_save"]}},"deny-client-state-set-restore-enabled":{"identifier":"deny-client-state-set-restore-enabled","description":"Denies the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_set_restore_enabled"]}},"deny-desktop-launch-acknowledge-folder":{"identifier":"deny-desktop-launch-acknowledge-folder","description":"Denies the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_acknowledge_folder"]}},"deny-desktop-launch-next-folder":{"identifier":"deny-desktop-launch-next-folder","description":"Denies the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_next_folder"]}},"deny-desktop-launch-ready":{"identifier":"deny-desktop-launch-ready","description":"Denies the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_ready"]}},"deny-developer-mode-get":{"identifier":"deny-developer-mode-get","description":"Denies the developer_mode_get command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_mode_get"]}},"deny-developer-mode-set":{"identifier":"deny-developer-mode-set","description":"Denies the developer_mode_set command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_mode_set"]}},"deny-install-stable-update":{"identifier":"deny-install-stable-update","description":"Denies the install_stable_update command without any pre-configured scope.","commands":{"allow":[],"deny":["install_stable_update"]}},"deny-open-preferences-window":{"identifier":"deny-open-preferences-window","description":"Denies the open_preferences_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_preferences_window"]}},"deny-open-workspace-target":{"identifier":"deny-open-workspace-target","description":"Denies the open_workspace_target command without any pre-configured scope.","commands":{"allow":[],"deny":["open_workspace_target"]}},"deny-popup-titlebar-menu":{"identifier":"deny-popup-titlebar-menu","description":"Denies the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["popup_titlebar_menu"]}},"deny-preferences-accept-request":{"identifier":"deny-preferences-accept-request","description":"Denies the preferences_accept_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_accept_request"]}},"deny-preferences-get-request":{"identifier":"deny-preferences-get-request","description":"Denies the preferences_get_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_get_request"]}},"deny-preferences-resolve-transition":{"identifier":"deny-preferences-resolve-transition","description":"Denies the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_resolve_transition"]}},"deny-preferences-window-ready":{"identifier":"deny-preferences-window-ready","description":"Denies the preferences_window_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_window_ready"]}},"deny-set-workspace-menu-enabled":{"identifier":"deny-set-workspace-menu-enabled","description":"Denies the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_workspace_menu_enabled"]}},"deny-wake-lock-start":{"identifier":"deny-wake-lock-start","description":"Denies the wake_lock_start command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_start"]}},"deny-wake-lock-stop":{"identifier":"deny-wake-lock-stop","description":"Denies the wake_lock_stop command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_stop"]}},"deny-window-control":{"identifier":"deny-window-control","description":"Denies the window_control command without any pre-configured scope.","commands":{"allow":[],"deny":["window_control"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json index 83f286aaf..8a9d847e5 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json +++ b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant local windows access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:1420","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["local-*"],"permissions":["core:default","core:menu:default","allow-window-control","allow-popup-titlebar-menu","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom","allow-cli-get-status","allow-cli-restart","allow-wake-lock-start","allow-wake-lock-stop","allow-needs-local-certificate-install","allow-open-preferences-window","allow-open-remote-window","allow-client-state-claim-access","allow-client-state-load","allow-client-state-save","allow-client-state-commit-partitions","allow-client-state-load-partition","allow-client-state-set-restore-enabled","allow-client-state-clear","allow-client-state-renderer-flushed","allow-client-state-navigation-flushed","allow-desktop-launch-ready","allow-desktop-launch-next-folder","allow-desktop-launch-acknowledge-folder","allow-install-stable-update","allow-open-workspace-target","allow-set-workspace-menu-enabled","allow-developer-mode-get","allow-developer-mode-set"]},"preferences-window":{"identifier":"preferences-window","description":"Grant the singleton Preferences window only settings and HTML window-chrome capabilities.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","https://127.0.0.1:*","https://localhost:*"]},"local":true,"windows":["preferences"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-preferences-window-ready","allow-preferences-get-request","allow-preferences-accept-request","allow-preferences-resolve-transition","allow-window-control","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","allow-cli-get-status","allow-cli-restart","allow-needs-local-certificate-install","allow-open-remote-window"]},"remote-window-notifications":{"identifier":"remote-window-notifications","description":"Grant remote CodeNomad windows access only to native OS notifications.","remote":{"urls":["http://*:*","https://*:*"]},"local":false,"windows":["remote-*"],"permissions":["notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]}} \ No newline at end of file +{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant local windows access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:1420","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["local-*"],"permissions":["core:default","core:menu:default","allow-window-control","allow-popup-titlebar-menu","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom","allow-cli-get-status","allow-cli-restart","allow-wake-lock-start","allow-wake-lock-stop","allow-open-preferences-window","allow-client-state-claim-access","allow-client-state-load","allow-client-state-save","allow-client-state-commit-partitions","allow-client-state-load-partition","allow-client-state-set-restore-enabled","allow-client-state-clear","allow-client-state-renderer-flushed","allow-client-state-navigation-flushed","allow-desktop-launch-ready","allow-desktop-launch-next-folder","allow-desktop-launch-acknowledge-folder","allow-install-stable-update","allow-open-workspace-target","allow-set-workspace-menu-enabled","allow-developer-mode-get","allow-developer-mode-set"]},"preferences-window":{"identifier":"preferences-window","description":"Grant the singleton Preferences window only settings and HTML window-chrome capabilities.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","https://127.0.0.1:*","https://localhost:*"]},"local":true,"windows":["preferences"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-preferences-window-ready","allow-preferences-get-request","allow-preferences-accept-request","allow-preferences-resolve-transition","allow-window-control","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","allow-cli-get-status","allow-cli-restart"]}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json b/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json index 21dc3c2d2..cbcb73dbc 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json @@ -446,24 +446,12 @@ "const": "allow-install-stable-update", "markdownDescription": "Enables the install_stable_update command without any pre-configured scope." }, - { - "description": "Enables the needs_local_certificate_install command without any pre-configured scope.", - "type": "string", - "const": "allow-needs-local-certificate-install", - "markdownDescription": "Enables the needs_local_certificate_install command without any pre-configured scope." - }, { "description": "Enables the open_preferences_window command without any pre-configured scope.", "type": "string", "const": "allow-open-preferences-window", "markdownDescription": "Enables the open_preferences_window command without any pre-configured scope." }, - { - "description": "Enables the open_remote_window command without any pre-configured scope.", - "type": "string", - "const": "allow-open-remote-window", - "markdownDescription": "Enables the open_remote_window command without any pre-configured scope." - }, { "description": "Enables the open_workspace_target command without any pre-configured scope.", "type": "string", @@ -626,24 +614,12 @@ "const": "deny-install-stable-update", "markdownDescription": "Denies the install_stable_update command without any pre-configured scope." }, - { - "description": "Denies the needs_local_certificate_install command without any pre-configured scope.", - "type": "string", - "const": "deny-needs-local-certificate-install", - "markdownDescription": "Denies the needs_local_certificate_install command without any pre-configured scope." - }, { "description": "Denies the open_preferences_window command without any pre-configured scope.", "type": "string", "const": "deny-open-preferences-window", "markdownDescription": "Denies the open_preferences_window command without any pre-configured scope." }, - { - "description": "Denies the open_remote_window command without any pre-configured scope.", - "type": "string", - "const": "deny-open-remote-window", - "markdownDescription": "Denies the open_remote_window command without any pre-configured scope." - }, { "description": "Denies the open_workspace_target command without any pre-configured scope.", "type": "string", diff --git a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json index 21dc3c2d2..cbcb73dbc 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json @@ -446,24 +446,12 @@ "const": "allow-install-stable-update", "markdownDescription": "Enables the install_stable_update command without any pre-configured scope." }, - { - "description": "Enables the needs_local_certificate_install command without any pre-configured scope.", - "type": "string", - "const": "allow-needs-local-certificate-install", - "markdownDescription": "Enables the needs_local_certificate_install command without any pre-configured scope." - }, { "description": "Enables the open_preferences_window command without any pre-configured scope.", "type": "string", "const": "allow-open-preferences-window", "markdownDescription": "Enables the open_preferences_window command without any pre-configured scope." }, - { - "description": "Enables the open_remote_window command without any pre-configured scope.", - "type": "string", - "const": "allow-open-remote-window", - "markdownDescription": "Enables the open_remote_window command without any pre-configured scope." - }, { "description": "Enables the open_workspace_target command without any pre-configured scope.", "type": "string", @@ -626,24 +614,12 @@ "const": "deny-install-stable-update", "markdownDescription": "Denies the install_stable_update command without any pre-configured scope." }, - { - "description": "Denies the needs_local_certificate_install command without any pre-configured scope.", - "type": "string", - "const": "deny-needs-local-certificate-install", - "markdownDescription": "Denies the needs_local_certificate_install command without any pre-configured scope." - }, { "description": "Denies the open_preferences_window command without any pre-configured scope.", "type": "string", "const": "deny-open-preferences-window", "markdownDescription": "Denies the open_preferences_window command without any pre-configured scope." }, - { - "description": "Denies the open_remote_window command without any pre-configured scope.", - "type": "string", - "const": "deny-open-remote-window", - "markdownDescription": "Denies the open_remote_window command without any pre-configured scope." - }, { "description": "Denies the open_workspace_target command without any pre-configured scope.", "type": "string", diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml deleted file mode 100644 index 8870800b9..000000000 --- a/packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -[[permission]] -identifier = "allow-needs-local-certificate-install" -description = "Enables the needs_local_certificate_install command without any pre-configured scope." -commands.allow = ["needs_local_certificate_install"] - -[[permission]] -identifier = "deny-needs-local-certificate-install" -description = "Denies the needs_local_certificate_install command without any pre-configured scope." -commands.deny = ["needs_local_certificate_install"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml deleted file mode 100644 index 1c8c6e886..000000000 --- a/packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -[[permission]] -identifier = "allow-open-remote-window" -description = "Enables the open_remote_window command without any pre-configured scope." -commands.allow = ["open_remote_window"] - -[[permission]] -identifier = "deny-open-remote-window" -description = "Denies the open_remote_window command without any pre-configured scope." -commands.deny = ["open_remote_window"] diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index a575b1bdc..2ab1dfc62 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1,15 +1,12 @@ use crate::managed_node::resolve_bundled_node_binary; -use dirs::home_dir; use parking_lot::Mutex; use regex::Regex; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::json; use std::collections::VecDeque; -use std::env; #[cfg(windows)] use std::ffi::c_void; use std::ffi::OsStr; -use std::fs; use std::io::{BufRead, BufReader, Read, Write}; #[cfg(windows)] use std::mem::{size_of, zeroed}; @@ -638,126 +635,6 @@ fn generate_auth_cookie_name() -> String { format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}") } -const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; - -#[derive(Debug, Deserialize)] -struct PreferencesConfig { - #[serde(rename = "listeningMode")] - listening_mode: Option, -} - -#[derive(Debug, Deserialize)] -struct ServerConfig { - #[serde(rename = "listeningMode")] - listening_mode: Option, -} - -#[derive(Debug, Deserialize)] -struct AppConfig { - preferences: Option, - server: Option, -} - -fn resolve_config_locations() -> (PathBuf, PathBuf) { - let raw = env::var("CLI_CONFIG") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); - - let expanded = expand_home(&raw); - let lower = raw.trim().to_lowercase(); - - if lower.ends_with(".yaml") || lower.ends_with(".yml") { - let base = expanded - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| expanded.clone()); - return (expanded, base.join("config.json")); - } - - if lower.ends_with(".json") { - let base = expanded - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| expanded.clone()); - return (base.join("config.yaml"), expanded); - } - - // Treat as directory. - (expanded.join("config.yaml"), expanded.join("config.json")) -} - -fn expand_home(path: &str) -> PathBuf { - if path.starts_with("~/") { - if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) { - return home.join(path.trim_start_matches("~/")); - } - } - PathBuf::from(path) -} - -fn resolve_listening_mode() -> String { - let (yaml_path, json_path) = resolve_config_locations(); - - if let Ok(content) = fs::read_to_string(&yaml_path) { - if let Ok(config) = serde_yaml::from_str::(&content) { - let mode = config - .server - .as_ref() - .and_then(|srv| srv.listening_mode.as_ref()) - .or_else(|| { - config - .preferences - .as_ref() - .and_then(|prefs| prefs.listening_mode.as_ref()) - }); - - if let Some(mode) = mode { - if mode == "local" { - return "local".to_string(); - } - if mode == "all" { - return "all".to_string(); - } - } - } - } - - // Legacy fallback. - if let Ok(content) = fs::read_to_string(&json_path) { - if let Ok(config) = serde_json::from_str::(&content) { - let mode = config - .server - .as_ref() - .and_then(|srv| srv.listening_mode.as_ref()) - .or_else(|| { - config - .preferences - .as_ref() - .and_then(|prefs| prefs.listening_mode.as_ref()) - }); - if let Some(mode) = mode { - if mode == "local" { - return "local".to_string(); - } - if mode == "all" { - return "all".to_string(); - } - } - } - } - "local".to_string() -} - -fn resolve_listening_host() -> String { - let mode = resolve_listening_mode(); - if mode == "local" { - "127.0.0.1".to_string() - } else { - "0.0.0.0".to_string() - } -} - #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum CliState { @@ -1101,13 +978,12 @@ impl CliProcessManager { }; log_line("resolving CLI entry"); let resolution = CliEntry::resolve(&app, dev)?; - let host = resolve_listening_host(); log_line(&format!( - "resolved CLI entry runner={:?} entry={} host={}", - resolution.runner, resolution.entry, host + "resolved CLI entry runner={:?} entry={} host=127.0.0.1", + resolution.runner, resolution.entry )); let auth_cookie_name = Arc::new(generate_auth_cookie_name()); - let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); + let args = resolution.build_args(dev, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { log_line("development mode: will prefer tsx + source if present"); @@ -1693,11 +1569,9 @@ impl CliEntry { )) } - fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec { + fn build_args(&self, dev: bool, auth_cookie_name: &str) -> Vec { let mut args = vec![ "serve".to_string(), - "--host".to_string(), - host.to_string(), "--auth-cookie-name".to_string(), auth_cookie_name.to_string(), "--generate-token".to_string(), @@ -1705,8 +1579,7 @@ impl CliEntry { ]; if dev { - // Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so - // remote proxy sessions can still spin up secure local windows. + // Dev: keep loopback HTTP for the Vite proxy. let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL") .ok() .filter(|value| !value.trim().is_empty()) diff --git a/packages/tauri-app/src-tauri/src/linux_tls.rs b/packages/tauri-app/src-tauri/src/linux_tls.rs deleted file mode 100644 index 057020e36..000000000 --- a/packages/tauri-app/src-tauri/src/linux_tls.rs +++ /dev/null @@ -1,104 +0,0 @@ -use crate::{clear_remote_tls_handler, AppState}; -use tauri::{AppHandle, Manager, WebviewWindow}; -use url::Url; -use webkit2gtk::{WebContextExt, WebView, WebViewExt}; - -pub fn should_bootstrap_tls_navigation(target_url: &Url, allow_tls_certificate: bool) -> bool { - allow_tls_certificate && target_url.scheme() == "https" -} - -pub fn ensure_remote_window_tls_handler( - window: &WebviewWindow, - app_handle: &AppHandle, - window_label: &str, - window_generation: u64, -) -> Result<(), String> { - { - let state = app_handle.state::(); - let mut handlers = state - .remote_tls_handlers - .lock() - .map_err(|err| err.to_string())?; - if handlers.get(window_label).copied() == Some(window_generation) { - return Ok(()); - } - handlers.insert(window_label.to_string(), window_generation); - } - - let handler_app = app_handle.clone(); - let handler_label = window_label.to_string(); - window - .with_webview(move |platform_webview| { - let webview = platform_webview.inner(); - let app_handle = handler_app.clone(); - let window_label = handler_label.clone(); - webview.connect_load_failed_with_tls_errors( - move |view, failing_uri, certificate, _| { - allow_remote_tls_certificate( - &app_handle, - &window_label, - window_generation, - view, - failing_uri, - certificate, - ) - }, - ); - }) - .map_err(|err| { - if let Ok(mut handlers) = app_handle.state::().remote_tls_handlers.lock() { - clear_remote_tls_handler(&mut handlers, &window_label, window_generation); - } - err.to_string() - }) -} - -fn allow_remote_tls_certificate( - app_handle: &AppHandle, - window_label: &str, - window_generation: u64, - view: &WebView, - failing_uri: &str, - certificate: &webkit2gtk::gio::TlsCertificate, -) -> bool { - let Ok(parsed_uri) = Url::parse(failing_uri) else { - return false; - }; - let Some(host) = parsed_uri.host_str() else { - return false; - }; - - let state = app_handle.state::(); - if state.remote_tls_handlers.lock().map_or(true, |handlers| { - handlers.get(window_label).copied() != Some(window_generation) - }) { - return false; - } - let metadata = state - .remote_navigation - .lock() - .ok() - .and_then(|values| values.get(window_label).cloned()); - let Some(metadata) = metadata else { - return false; - }; - if !metadata.allow_linux_tls_certificate { - return false; - } - if metadata.window_generation != window_generation { - return false; - } - - let parsed_origin = parsed_uri.origin().ascii_serialization(); - if metadata.origin != parsed_origin { - return false; - } - - let Some(context) = view.context() else { - return false; - }; - - context.allow_tls_certificate_for_host(certificate, host); - view.load_uri(failing_uri); - true -} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 272f8f2a7..36a17ca9a 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -7,8 +7,6 @@ mod client_state; mod developer_mode; mod identity; mod launch; -#[cfg(target_os = "linux")] -mod linux_tls; mod local_windows; mod managed_node; mod native_request; @@ -22,22 +20,19 @@ use keepawake::KeepAwake; use serde::Deserialize; use serde_json::json; use sha2::{Digest, Sha256}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; #[cfg(any(windows, test))] use std::future::Future; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tauri::async_runtime::Mutex as AsyncMutex; use tauri::menu::{ AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder, }; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::webview::{PageLoadEvent, Webview}; -use tauri::{ - AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, -}; +use tauri::{AppHandle, Emitter, Manager, Runtime, Wry}; use tauri_plugin_global_shortcut::{ Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState, }; @@ -54,86 +49,17 @@ use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; const ZOOM_STEP: f64 = 0.1; -const REMOTE_PROXY_CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); const RELEASES_URL: &str = "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"; -const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = - "window.__CODENOMAD_RUNTIME_HOST__ = 'tauri'; window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';"; pub struct AppState { pub manager: CliProcessManager, pub(crate) developer_mode: developer_mode::DeveloperMode, pub wake_lock: Mutex, - remote_navigation: Mutex>, - remote_navigation_generation: AtomicU64, - remote_profiles: Mutex>, - remote_window_operations: RemoteWindowOperationLocks, - remote_proxy_cleanup_claims: Mutex>, - pub remote_tls_handlers: Mutex>, - pub remote_zoom_levels: Mutex>, pub workspace_menu_items: Mutex>, pub webview_data_directory: std::path::PathBuf, pub developer_browser_arguments: Option, pub scoped_profile: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct RemoteWindowMetadata { - origin: String, - title: String, - allow_linux_tls_certificate: bool, - generation: u64, - window_generation: u64, -} - -struct StagedRemoteWindowMetadata { - generation: u64, - window_generation: u64, - previous: Option, -} - -#[derive(Default)] -struct RemoteWindowOperationLocks { - values: Mutex>>>, -} - -impl RemoteWindowOperationLocks { - fn for_label(&self, label: &str) -> Result>, String> { - Ok(self - .values - .lock() - .map_err(|err| err.to_string())? - .entry(label.to_string()) - .or_insert_with(|| Arc::new(AsyncMutex::new(()))) - .clone()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum RemoteProfileIdentity { - Direct, - Proxy(String), -} - -impl RemoteProfileIdentity { - fn new(proxy_session_id: Option<&str>) -> Self { - proxy_session_id - .map(|value| Self::Proxy(value.to_string())) - .unwrap_or(Self::Direct) - } - - fn proxy_session_id(&self) -> Option<&str> { - match self { - Self::Direct => None, - Self::Proxy(value) => Some(value), - } - } -} - -fn should_recreate_remote_window( - existing: Option<&RemoteProfileIdentity>, - requested: &RemoteProfileIdentity, -) -> bool { - existing != Some(requested) + keep_alive_for_remote_control: AtomicBool, } #[derive(Default)] @@ -275,51 +201,6 @@ fn set_workspace_menu_enabled( Ok(()) } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RemoteWindowPayload { - id: String, - name: String, - base_url: String, - entry_url: Option, - proxy_session_id: Option, - #[allow(dead_code)] - skip_tls_verify: bool, -} - -fn require_http_url(value: &str, name: &str) -> Result { - let url = Url::parse(value).map_err(|error| error.to_string())?; - if !matches!(url.scheme(), "http" | "https") { - return Err(format!("{name} must use HTTP or HTTPS")); - } - Ok(url) -} - -fn claim_unowned_remote_proxy_session( - profiles: &HashMap, - claims: &mut HashSet, - session_id: &str, -) -> bool { - if profiles - .values() - .any(|profile| profile.proxy_session_id() == Some(session_id)) - { - return false; - } - claims.insert(session_id.to_string()) -} - -fn claim_remote_proxy_session_cleanup(app: &AppHandle, session_id: &str) -> bool { - let state = app.state::(); - let Ok(profiles) = state.remote_profiles.lock() else { - return false; - }; - let Ok(mut claims) = state.remote_proxy_cleanup_claims.lock() else { - return false; - }; - claim_unowned_remote_proxy_session(&profiles, &mut claims, session_id) -} - #[tauri::command] fn developer_mode_get( window: tauri::WebviewWindow, @@ -410,101 +291,6 @@ async fn popup_titlebar_menu( .map_err(|error| error.to_string()) } -fn release_remote_proxy_session_cleanup(app: &AppHandle, session_id: &str) { - if let Ok(mut claims) = app.state::().remote_proxy_cleanup_claims.lock() { - claims.remove(session_id); - } -} - -async fn cleanup_remote_proxy_session_if_unowned(app: &AppHandle, session_id: &str) { - if !claim_remote_proxy_session_cleanup(app, session_id) { - return; - } - if let Err(err) = cleanup_remote_proxy_session(app, session_id).await { - release_remote_proxy_session_cleanup(app, session_id); - eprintln!( - "[tauri] failed to clean up remote proxy session {}: {}", - session_id, err - ); - } -} - -fn schedule_remote_proxy_session_cleanup(app: AppHandle, label: String, session_id: String) { - tauri::async_runtime::spawn(async move { - let Ok(operation) = app - .state::() - .remote_window_operations - .for_label(&label) - else { - return; - }; - let _guard = operation.lock().await; - cleanup_remote_proxy_session_if_unowned(&app, &session_id).await; - }); -} - -fn schedule_remote_window_destroyed_cleanup( - app: AppHandle, - label: String, - profile: RemoteProfileIdentity, - window_generation: u64, -) { - tauri::async_runtime::spawn(async move { - let Ok(operation) = app - .state::() - .remote_window_operations - .for_label(&label) - else { - return; - }; - let _guard = operation.lock().await; - clear_remote_window_metadata(&app, &label, &profile, window_generation); - if let Some(session_id) = profile.proxy_session_id() { - cleanup_remote_proxy_session_if_unowned(&app, session_id).await; - } - }); -} - -async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> { - let status = app.state::().manager.status(); - let Some(base_url) = status.url else { - return Err("backend is unavailable".to_string()); - }; - - let mut cleanup_url = Url::parse(&base_url).map_err(|err| err.to_string())?; - cleanup_url.set_path(&format!("/api/remote-proxy/sessions/{session_id}")); - cleanup_url.set_query(None); - cleanup_url.set_fragment(None); - - let client = if cleanup_url.scheme() == "https" { - let local_cert = cert_manager::ensure_local_cert()?; - let ca_cert = reqwest::Certificate::from_der(&local_cert.ca_cert_der) - .map_err(|err| err.to_string())?; - reqwest::Client::builder() - .add_root_certificate(ca_cert) - .timeout(REMOTE_PROXY_CLEANUP_TIMEOUT) - .build() - .map_err(|err| err.to_string())? - } else { - reqwest::Client::builder() - .timeout(REMOTE_PROXY_CLEANUP_TIMEOUT) - .build() - .map_err(|err| err.to_string())? - }; - - let response = client - .delete(cleanup_url.as_str()) - .send() - .await - .map_err(|err| err.to_string())?; - - if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(()); - } - - Err(format!("unexpected status {}", response.status())) -} - #[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "camelCase")] struct WakeLockConfig { @@ -602,543 +388,98 @@ fn should_allow_window_origin( url: &Url, ) -> bool { let state = app_handle.state::(); - if identity::local_window_id(window_label).is_ok() || window_label == preferences_window::LABEL + if identity::local_window_id(window_label).is_err() && window_label != preferences_window::LABEL { - let status = state.manager.status(); - return is_allowed_local_origin(url, status.url.as_deref()); - } - let Ok(allowed) = state.remote_navigation.lock() else { return false; - }; - should_allow_registered_origin( - allowed - .get(window_label) - .map(|metadata| metadata.origin.as_str()), - url, - ) -} - -fn should_allow_registered_origin(registered_origin: Option<&str>, url: &Url) -> bool { - if let Some(origin) = registered_origin { - return (matches!(url.scheme(), "http" | "https") - && origin == url.origin().ascii_serialization()) - || url.as_str() == "about:blank"; } - should_allow_internal(url) + let status = state.manager.status(); + is_allowed_local_origin(url, status.url.as_deref()) } fn should_open_external_url(url: &Url) -> bool { matches!(url.scheme(), "http" | "https" | "mailto") } -fn intercept_navigation(webview: &Webview, url: &Url) -> bool { - let window_label = webview.label().to_string(); - if should_allow_window_origin(&webview.app_handle(), &window_label, url) { - return true; - } - - if should_open_external_url(url) { - if let Err(err) = webview - .app_handle() - .opener() - .open_url(url.as_str(), None::<&str>) - { - eprintln!("[tauri] failed to open external link {}: {}", url, err); - } - } - false -} - -fn apply_remote_window_title(app_handle: &AppHandle, window_label: &str) { - let Some(title) = app_handle - .state::() - .remote_navigation - .lock() - .ok() - .and_then(|values| { - values - .get(window_label) - .map(|metadata| metadata.title.clone()) - }) - else { - return; +async fn remote_control_enabled(app: &AppHandle) -> bool { + let Some(access) = app.state::().manager.local_cli_access() else { + return false; }; - - if let Some(window) = app_handle.get_webview_window(window_label) { - let _ = window.set_title(&title); - } -} - -async fn open_remote_window_impl( - app: AppHandle, - payload: RemoteWindowPayload, -) -> Result<(), String> { - let label = format!("remote-{}", payload.id); - let requested_profile = RemoteProfileIdentity::new(payload.proxy_session_id.as_deref()); - let operation = app - .state::() - .remote_window_operations - .for_label(&label)?; - let _guard = operation.lock().await; - let result = open_remote_window_locked( - app.clone(), - payload, - label.clone(), - requested_profile.clone(), - ); - if result.is_err() { - if let Some(session_id) = requested_profile.proxy_session_id() { - schedule_remote_proxy_session_cleanup(app, label, session_id.to_string()); - } - } - result -} - -fn open_remote_window_locked( - app: AppHandle, - payload: RemoteWindowPayload, - label: String, - requested_profile: RemoteProfileIdentity, -) -> Result<(), String> { - require_http_url(&payload.base_url, "baseUrl")?; - let entry_url = payload - .entry_url - .as_deref() - .unwrap_or(payload.base_url.as_str()); - let parsed = require_http_url(entry_url, "entryUrl")?; - let title = format!("{} - {}", payload.name, payload.base_url); - - let window_url = parsed.clone(); - - let allow_linux_tls_certificate = parsed.scheme() == "https" - && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); - - let mut previous_profile = None; - - if let Some(existing) = app.get_webview_window(&label) { - previous_profile = app - .state::() - .remote_profiles - .lock() - .map_err(|err| err.to_string())? - .get(&label) - .cloned(); - if should_recreate_remote_window(previous_profile.as_ref(), &requested_profile) { - app.state::() - .remote_profiles - .lock() - .map_err(|err| err.to_string())? - .insert(label.clone(), requested_profile.clone()); - if let Err(error) = existing.destroy() { - let state = app.state::(); - let mut profiles = state - .remote_profiles - .lock() - .map_err(|err| err.to_string())?; - if let Some(previous) = previous_profile.as_ref() { - profiles.insert(label.clone(), previous.clone()); - } else { - profiles.remove(&label); - } - return Err(error.to_string()); - } - if let Ok(mut handlers) = app.state::().remote_tls_handlers.lock() { - handlers.remove(&label); - } - } else { - let staged = set_remote_window_metadata( - &app, - &label, - &window_url, - &title, - allow_linux_tls_certificate, - false, - )?; - #[cfg(target_os = "linux")] - if let Err(error) = linux_tls::ensure_remote_window_tls_handler( - &existing, - &app, - &label, - staged.window_generation, - ) { - restore_remote_window_metadata(&app, &label, staged); - return Err(error); - } - apply_remote_window_title(&app, &label); - if let Err(error) = existing.navigate(window_url.clone()) { - if restore_remote_window_metadata(&app, &label, staged) { - apply_remote_window_title(&app, &label); - } - return Err(error.to_string()); - } - apply_remote_window_title(&app, &label); - let _ = existing.show(); - let _ = existing.unminimize(); - let _ = existing.set_focus(); - return Ok(()); - } - } else { - app.state::() - .remote_profiles - .lock() - .map_err(|err| err.to_string())? - .insert(label.clone(), requested_profile.clone()); - } - - let staged = match set_remote_window_metadata( - &app, - &label, - &window_url, - &title, - allow_linux_tls_certificate, - true, - ) { - Ok(staged) => staged, - Err(error) => { - clear_remote_profile(&app, &label, &requested_profile); - if let Some(session_id) = previous_profile - .as_ref() - .and_then(RemoteProfileIdentity::proxy_session_id) - { - schedule_remote_proxy_session_cleanup( - app.clone(), - label.clone(), - session_id.to_string(), - ); - } - return Err(error); - } + let Ok(mut url) = Url::parse(&access.base_url) else { + return false; }; - - let window_generation = staged.window_generation; - - #[cfg(target_os = "linux")] - let initial_url = - if linux_tls::should_bootstrap_tls_navigation(&window_url, allow_linux_tls_certificate) { - Url::parse("about:blank").expect("about:blank is a valid URL") - } else { - window_url.clone() + url.set_path("/api/remote-control/status"); + url.set_query(None); + url.set_fragment(None); + + let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(2)); + if url.scheme() == "https" { + let Ok(local_cert) = cert_manager::ensure_local_cert() else { + return false; }; - - #[cfg(not(target_os = "linux"))] - let initial_url = window_url.clone(); - - let profile_key = match requested_profile.proxy_session_id() { - Some(session_id) => format!("{}\0{session_id}", payload.id), - None => payload.id.clone(), - }; - let profile_hash = Sha256::digest(profile_key.as_bytes()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - let data_directory = app - .state::() - .webview_data_directory - .join("remote") - .join(profile_hash); - let builder = WebviewWindowBuilder::new( - &app, - label.clone(), - WebviewUrl::External(initial_url.clone()), - ) - .data_directory(data_directory) - .incognito(requested_profile.proxy_session_id().is_some()) - .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) - .title(title) - .inner_size(1400.0, 900.0) - .min_inner_size(800.0, 600.0); - #[cfg(target_os = "macos")] - let builder = builder.data_store_identifier(profile_identifier(&profile_key)); - let window = match builder.build() { - Ok(window) => window, - Err(error) => { - cleanup_failed_remote_window( - &app, - None, - &label, - &requested_profile, - previous_profile.as_ref(), - window_generation, - ); - return Err(error.to_string()); - } - }; - - #[cfg(windows)] - if let Err(error) = shutdown::schedule_windows_session_end_handler(&window) { - cleanup_failed_remote_window( - &app, - Some(&window), - &label, - &requested_profile, - previous_profile.as_ref(), - window_generation, - ); - return Err(error); - } - - #[cfg(target_os = "linux")] - { - let setup = - linux_tls::ensure_remote_window_tls_handler(&window, &app, &label, window_generation) - .and_then(|()| { - if initial_url == window_url { - Ok(()) - } else { - window - .navigate(window_url.clone()) - .map_err(|err| err.to_string()) - } - }); - if let Err(error) = setup { - cleanup_failed_remote_window( - &app, - Some(&window), - &label, - &requested_profile, - previous_profile.as_ref(), - window_generation, - ); - return Err(error); - } - } - - if let Some(session_id) = previous_profile - .as_ref() - .filter(|profile| *profile != &requested_profile) - .and_then(RemoteProfileIdentity::proxy_session_id) - { - schedule_remote_proxy_session_cleanup(app.clone(), label.clone(), session_id.to_string()); - } - - let app_handle = app.clone(); - let label_for_cleanup = label.clone(); - let profile_for_cleanup = requested_profile.clone(); - window.on_window_event(move |event| { - if matches!(event, WindowEvent::Focused(_)) { - update_workspace_menu_state(&app_handle); - } - if let WindowEvent::Destroyed = event { - schedule_remote_window_destroyed_cleanup( - app_handle.clone(), - label_for_cleanup.clone(), - profile_for_cleanup.clone(), - window_generation, - ); - } - }); - - Ok(()) -} - -fn set_remote_window_metadata( - app: &AppHandle, - label: &str, - url: &Url, - title: &str, - allow_linux_tls_certificate: bool, - new_window: bool, -) -> Result { - let state = app.state::(); - let generation = state - .remote_navigation_generation - .fetch_add(1, Ordering::SeqCst) - + 1; - let mut values = state - .remote_navigation - .lock() - .map_err(|err| err.to_string())?; - let previous = values.get(label).cloned(); - let window_generation = if new_window { - generation - } else { - previous - .as_ref() - .map(|metadata| metadata.window_generation) - .unwrap_or(generation) - }; - values.insert( - label.to_string(), - RemoteWindowMetadata { - origin: url.origin().ascii_serialization(), - title: title.to_string(), - allow_linux_tls_certificate, - generation, - window_generation, - }, - ); - Ok(StagedRemoteWindowMetadata { - generation, - window_generation, - previous, - }) -} - -fn clear_remote_tls_handler( - handlers: &mut HashMap, - label: &str, - window_generation: u64, -) -> bool { - if handlers.get(label).copied() != Some(window_generation) { - return false; + let Ok(ca_cert) = reqwest::Certificate::from_der(&local_cert.ca_cert_der) else { + return false; + }; + builder = builder.add_root_certificate(ca_cert); } - handlers.remove(label); - true -} - -fn rollback_remote_window_metadata( - values: &mut HashMap, - label: &str, - failed_generation: u64, - previous: Option, -) -> bool { - if values.get(label).map(|metadata| metadata.generation) != Some(failed_generation) { + let Ok(client) = builder.build() else { return false; - } - match previous { - Some(previous) => { - values.insert(label.to_string(), previous); - } - None => { - values.remove(label); - } - } - true -} - -fn restore_remote_window_metadata( - app: &AppHandle, - label: &str, - staged: StagedRemoteWindowMetadata, -) -> bool { - app.state::() - .remote_navigation - .lock() - .ok() - .is_some_and(|mut values| { - rollback_remote_window_metadata(&mut values, label, staged.generation, staged.previous) - }) -} - -fn clear_remote_profile(app: &AppHandle, label: &str, profile: &RemoteProfileIdentity) -> bool { - let state = app.state::(); - let Ok(mut profiles) = state.remote_profiles.lock() else { + }; + let Ok(response) = client + .get(url) + .header( + reqwest::header::COOKIE, + format!("{}={}", access.cookie_name, access.session_cookie), + ) + .send() + .await + else { return false; }; - if profiles.get(label) != Some(profile) { + if !response.status().is_success() { return false; } - profiles.remove(label); - true + response + .json::() + .await + .ok() + .and_then(|value| value.get("enabled").and_then(serde_json::Value::as_bool)) + .unwrap_or(false) } -fn cleanup_failed_remote_window( - app: &AppHandle, - window: Option<&tauri::WebviewWindow>, - label: &str, - profile: &RemoteProfileIdentity, - previous_profile: Option<&RemoteProfileIdentity>, - window_generation: u64, -) { - if let Some(window) = window { - let _ = window.destroy(); - } - if clear_remote_window_metadata(app, label, profile, window_generation) { - if let Some(session_id) = previous_profile.and_then(RemoteProfileIdentity::proxy_session_id) - { - schedule_remote_proxy_session_cleanup( - app.clone(), - label.to_string(), - session_id.to_string(), - ); +fn request_final_local_window_close(app: AppHandle, label: String) { + tauri::async_runtime::spawn(async move { + let keep_alive = remote_control_enabled(&app).await; + app.state::() + .keep_alive_for_remote_control + .store(keep_alive, Ordering::SeqCst); + if keep_alive { + shutdown::request_local_window_close(app, label); + } else { + shutdown::request(app); } - } + }); } -fn clear_remote_window_metadata( - app: &AppHandle, - label: &str, - profile: &RemoteProfileIdentity, - window_generation: u64, -) -> bool { - let state = app.state::(); - let Ok(mut navigation) = state.remote_navigation.lock() else { - return false; - }; - if navigation - .get(label) - .map(|metadata| metadata.window_generation) - != Some(window_generation) - { - return false; - } - let Ok(mut profiles) = state.remote_profiles.lock() else { - return false; - }; - if profiles.get(label) != Some(profile) { - return false; - } - profiles.remove(label); - navigation.remove(label); - if let Ok(mut values) = state.remote_tls_handlers.lock() { - clear_remote_tls_handler(&mut values, label, window_generation); - } - true +fn should_shutdown_after_last_window(keep_alive_for_remote_control: bool) -> bool { + !keep_alive_for_remote_control } -#[tauri::command] -fn needs_local_certificate_install( - window: tauri::WebviewWindow, - state: tauri::State, -) -> Result { - require_preferences_or_local_app_window(&window, &state)?; - #[cfg(not(target_os = "linux"))] - { - let local_cert = cert_manager::ensure_local_cert().map_err(|err| { - format!("Failed to load the local HTTPS certificate for the remote proxy window: {err}") - })?; - return cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| { - format!("Failed to inspect the local CodeNomad certificate trust state: {err}") - }); - } - - #[cfg(target_os = "linux")] - { - Ok(false) +fn intercept_navigation(webview: &Webview, url: &Url) -> bool { + let window_label = webview.label().to_string(); + if should_allow_window_origin(&webview.app_handle(), &window_label, url) { + return true; } -} -#[tauri::command] -async fn open_remote_window( - window: tauri::WebviewWindow, - app: AppHandle, - state: tauri::State<'_, AppState>, - payload: RemoteWindowPayload, -) -> Result<(), String> { - require_preferences_or_local_app_window(&window, &state)?; - #[cfg(not(target_os = "linux"))] - { - let entry_url = payload - .entry_url - .as_deref() - .unwrap_or(payload.base_url.as_str()); - require_http_url(&payload.base_url, "baseUrl")?; - let parsed = require_http_url(entry_url, "entryUrl")?; - if payload.proxy_session_id.is_some() && parsed.scheme() == "https" { - let local_cert = cert_manager::ensure_local_cert().map_err(|err| { - format!( - "Failed to load the local HTTPS certificate for the remote proxy window: {err}" - ) - })?; - if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) { - return Err(format!( - "Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}" - )); - } + if should_open_external_url(url) { + if let Err(err) = webview + .app_handle() + .opener() + .open_url(url.as_str(), None::<&str>) + { + eprintln!("[tauri] failed to open external link {}: {}", url, err); } } - - open_remote_window_impl(app, payload).await + false } fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec { @@ -1266,13 +607,6 @@ fn toggle_fullscreen_window(app_handle: &AppHandle) { fn set_target_zoom(app: &AppHandle, window: &tauri::WebviewWindow, zoom: f64) { if identity::local_window_id(window.label()).is_ok() { client_state::set_local_window_zoom(app, window.label(), zoom); - return; - } - let zoom = zoom.clamp(0.25, 5.0); - if window.set_zoom(zoom).is_ok() { - if let Ok(mut levels) = app.state::().remote_zoom_levels.lock() { - levels.insert(window.label().to_string(), zoom); - } } } @@ -1280,12 +614,7 @@ fn target_zoom(app: &AppHandle, window: &tauri::WebviewWindow) -> f64 { if identity::local_window_id(window.label()).is_ok() { client_state::local_window_zoom(app, window.label()) } else { - app.state::() - .remote_zoom_levels - .lock() - .ok() - .and_then(|levels| levels.get(window.label()).copied()) - .unwrap_or(client_state::DEFAULT_ZOOM_LEVEL) + client_state::DEFAULT_ZOOM_LEVEL } } @@ -1507,17 +836,11 @@ fn main() { manager: CliProcessManager::new(), developer_mode, wake_lock: Mutex::new(WakeLockState::default()), - remote_navigation: Mutex::new(HashMap::new()), - remote_navigation_generation: AtomicU64::new(0), - remote_profiles: Mutex::new(HashMap::new()), - remote_window_operations: RemoteWindowOperationLocks::default(), - remote_proxy_cleanup_claims: Mutex::new(HashSet::new()), - remote_tls_handlers: Mutex::new(HashMap::new()), - remote_zoom_levels: Mutex::new(HashMap::new()), workspace_menu_items: Mutex::new(None), webview_data_directory, developer_browser_arguments, scoped_profile: setup_scope.scoped, + keep_alive_for_remote_control: AtomicBool::new(false), }) .on_page_load(|webview, payload| { if identity::local_window_id(webview.label()).is_ok() @@ -1543,12 +866,6 @@ fn main() { .set_workspace_menu_enabled(webview.label(), false); update_workspace_menu_state(&webview.app_handle()); } - if matches!( - payload.event(), - PageLoadEvent::Started | PageLoadEvent::Finished - ) { - apply_remote_window_title(&webview.app_handle(), webview.label()); - } if webview.label() == preferences_window::LABEL && payload.event() == PageLoadEvent::Finished { @@ -1588,7 +905,6 @@ fn main() { cli_restart, wake_lock_start, wake_lock_stop, - needs_local_certificate_install, preferences_window::open_preferences_window, preferences_window::preferences_window_ready, preferences_window::preferences_get_request, @@ -1596,7 +912,6 @@ fn main() { preferences_window::preferences_resolve_transition, window_control, popup_titlebar_menu, - open_remote_window, client_state::client_state_claim_access, client_state::client_state_load, client_state::client_state_save, @@ -1735,6 +1050,13 @@ fn main() { return; } api.prevent_exit(); + if app_handle + .state::() + .keep_alive_for_remote_control + .load(Ordering::SeqCst) + { + return; + } shutdown::request(app_handle.clone()); } tauri::RunEvent::WindowEvent { @@ -1812,7 +1134,11 @@ fn main() { is_final_application_window(&label, windows.keys().map(String::as_str)); if final_window { api.prevent_close(); - shutdown::request(app_handle.clone()); + if local_window { + request_final_local_window_close(app_handle.clone(), label); + } else { + shutdown::request(app_handle.clone()); + } return; } if local_window { @@ -1839,15 +1165,21 @@ fn main() { wake.handle.take(); } } - if let Ok(mut zoom) = app_handle.state::().remote_zoom_levels.lock() { - zoom.remove(&label); - } update_workspace_menu_state(&app_handle); update_fullscreen_shortcut(&app_handle); if !app_handle.webview_windows().is_empty() { return; } + if !should_shutdown_after_last_window( + app_handle + .state::() + .keep_alive_for_remote_control + .load(Ordering::SeqCst), + ) { + return; + } + // Stop the CLI only when the final window is gone and the app is // truly exiting. shutdown::request(app_handle.clone()); @@ -2161,12 +1493,9 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada #[cfg(test)] mod menu_tests { use super::{ - build_about_metadata, claim_unowned_remote_proxy_session, clear_remote_tls_handler, - is_allowed_local_origin, is_final_application_window, require_http_url, - rollback_remote_window_metadata, run_update_with_fallback, should_allow_registered_origin, - should_open_external_url, should_recreate_remote_window, titlebar_menu_id, - RemoteProfileIdentity, RemoteWindowMetadata, RemoteWindowOperationLocks, WakeLockState, - RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, + build_about_metadata, is_allowed_local_origin, is_final_application_window, + run_update_with_fallback, should_open_external_url, should_shutdown_after_last_window, + titlebar_menu_id, WakeLockState, RELEASES_URL, }; use serde_json::json; use std::sync::atomic::{AtomicBool, Ordering}; @@ -2184,10 +1513,16 @@ mod menu_tests { )); assert!(!is_final_application_window( "local-a", - ["local-a", "preferences", "remote-a"].into_iter(), + ["local-a", "preferences", "local-b"].into_iter(), )); } + #[test] + fn remote_control_keeps_the_backend_alive_without_windows() { + assert!(!should_shutdown_after_last_window(true)); + assert!(should_shutdown_after_last_window(false)); + } + #[test] fn titlebar_menu_ids_are_restricted_to_application_submenus() { assert_eq!(titlebar_menu_id("file"), Some("menu-file")); @@ -2225,53 +1560,6 @@ mod menu_tests { assert_eq!(metadata.website_label, None); } - #[test] - fn remote_windows_identify_as_remote_tauri_windows() { - assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_RUNTIME_HOST__ = 'tauri'")); - assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_WINDOW_CONTEXT__ = 'remote'")); - - let capability: serde_json::Value = serde_json::from_str(include_str!( - "../capabilities/remote-window-notifications.json" - )) - .unwrap(); - assert_eq!(capability["local"], false); - assert_eq!( - capability["remote"]["urls"], - json!(["http://*:*", "https://*:*"]) - ); - assert_eq!(capability["windows"], json!(["remote-*"])); - assert_eq!( - capability["permissions"], - json!([ - "notification:allow-is-permission-granted", - "notification:allow-request-permission", - "notification:allow-notify" - ]) - ); - - let config: serde_json::Value = - serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); - assert!(config["app"]["security"]["capabilities"] - .as_array() - .unwrap() - .contains(&json!("remote-window-notifications"))); - assert_eq!(config["app"]["windows"], json!([])); - let local: serde_json::Value = - serde_json::from_str(include_str!("../capabilities/main-window.json")).unwrap(); - assert_eq!(local["windows"], json!(["local-*"])); - assert!(local["permissions"] - .as_array() - .unwrap() - .contains(&json!("allow-cli-restart"))); - assert!(!capability["permissions"] - .as_array() - .unwrap() - .iter() - .any(|permission| permission - .as_str() - .is_some_and(|value| value.starts_with("allow-cli")))); - } - #[test] fn wake_lock_request_labels_are_reference_counted() { let mut state = WakeLockState::default(); @@ -2284,108 +1572,6 @@ mod menu_tests { ); } - #[test] - fn remote_windows_stay_on_their_registered_http_origin() { - let origin = "https://remote.example:9898"; - assert!(should_allow_registered_origin( - Some(origin), - &Url::parse("https://remote.example:9898/settings").unwrap() - )); - assert!(!should_allow_registered_origin( - Some(origin), - &Url::parse("http://localhost:9898/").unwrap() - )); - assert!(should_allow_registered_origin( - Some(origin), - &Url::parse("about:blank").unwrap() - )); - } - - #[test] - fn failed_remote_navigation_restores_exact_previous_authority() { - let previous = RemoteWindowMetadata { - origin: "https://old.example".into(), - title: "Old title".into(), - allow_linux_tls_certificate: false, - generation: 4, - window_generation: 2, - }; - let mut values = std::collections::HashMap::from([( - "remote-a".to_string(), - RemoteWindowMetadata { - origin: "https://new.example".into(), - title: "New title".into(), - allow_linux_tls_certificate: true, - generation: 5, - window_generation: 2, - }, - )]); - - assert!(rollback_remote_window_metadata( - &mut values, - "remote-a", - 5, - Some(previous.clone()), - )); - assert_eq!(values.get("remote-a"), Some(&previous)); - } - - #[test] - fn stale_remote_navigation_failure_cannot_rollback_newer_authority() { - let current = RemoteWindowMetadata { - origin: "https://newest.example".into(), - title: "Newest title".into(), - allow_linux_tls_certificate: true, - generation: 6, - window_generation: 3, - }; - let mut values = - std::collections::HashMap::from([("remote-a".to_string(), current.clone())]); - - assert!(!rollback_remote_window_metadata( - &mut values, - "remote-a", - 5, - None, - )); - assert_eq!(values.get("remote-a"), Some(¤t)); - } - - #[test] - fn stale_window_cleanup_cannot_remove_replacement_tls_handler() { - let mut handlers = std::collections::HashMap::from([("remote-a".to_string(), 2)]); - - assert!(!clear_remote_tls_handler(&mut handlers, "remote-a", 1)); - assert_eq!(handlers.get("remote-a"), Some(&2)); - assert!(clear_remote_tls_handler(&mut handlers, "remote-a", 2)); - assert!(!handlers.contains_key("remote-a")); - } - - #[test] - fn remote_window_urls_require_http_or_https() { - assert_eq!( - require_http_url("http://localhost:3000/app", "baseUrl") - .unwrap() - .scheme(), - "http" - ); - assert_eq!( - require_http_url("https://example.com/app", "entryUrl") - .unwrap() - .scheme(), - "https" - ); - for value in [ - "file:///tmp/app", - "data:text/html,hi", - "javascript:alert(1)", - ] { - assert!(require_http_url(value, "baseUrl") - .unwrap_err() - .contains("must use HTTP or HTTPS")); - } - } - #[test] fn external_navigation_allows_only_web_and_mail_urls() { for value in [ @@ -2432,67 +1618,6 @@ mod menu_tests { || permission == "opener:allow-open-url")); } - #[test] - fn remote_window_reuse_requires_exact_profile_identity() { - let direct = RemoteProfileIdentity::Direct; - let proxy_a = RemoteProfileIdentity::Proxy("a".into()); - let proxy_b = RemoteProfileIdentity::Proxy("b".into()); - assert!(!should_recreate_remote_window(Some(&direct), &direct)); - assert!(!should_recreate_remote_window(Some(&proxy_a), &proxy_a)); - assert!(should_recreate_remote_window(Some(&direct), &proxy_a)); - assert!(should_recreate_remote_window(Some(&proxy_a), &direct)); - assert!(should_recreate_remote_window(Some(&proxy_a), &proxy_b)); - assert!(should_recreate_remote_window(None, &direct)); - } - - #[test] - fn remote_window_operations_serialize_only_matching_labels() { - let operations = RemoteWindowOperationLocks::default(); - let first = operations.for_label("remote-a").unwrap(); - let same = operations.for_label("remote-a").unwrap(); - let other = operations.for_label("remote-b").unwrap(); - - tauri::async_runtime::block_on(async { - let _guard = first.lock().await; - assert!(same.try_lock().is_err()); - assert!(other.try_lock().is_ok()); - }); - } - - #[test] - fn proxy_cleanup_is_claimed_once_and_never_while_owned() { - let mut profiles = std::collections::HashMap::from([( - "remote-a".to_string(), - RemoteProfileIdentity::Proxy("previous".into()), - )]); - let mut claims = std::collections::HashSet::new(); - - assert!(!claim_unowned_remote_proxy_session( - &profiles, - &mut claims, - "previous", - )); - profiles.insert( - "remote-a".into(), - RemoteProfileIdentity::Proxy("newer".into()), - ); - assert!(claim_unowned_remote_proxy_session( - &profiles, - &mut claims, - "previous", - )); - assert!(!claim_unowned_remote_proxy_session( - &profiles, - &mut claims, - "previous", - )); - assert!(!claim_unowned_remote_proxy_session( - &profiles, - &mut claims, - "newer", - )); - } - #[test] fn local_navigation_rejects_remote_and_unrelated_loopback_origins() { let managed = "http://127.0.0.1:43123"; diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index 66b0b7e56..fec8d5786 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -15,8 +15,7 @@ "security": { "capabilities": [ "main-window-native-dialogs", - "preferences-window", - "remote-window-notifications" + "preferences-window" ] } }, diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index c7bf08093..8adaadd02 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -1,6 +1,6 @@ import { Dialog } from "@kobalte/core/dialog" import { Component, createMemo, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" -import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, X, Globe, Loader2, GitBranch, Pencil } from "lucide-solid" +import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, X, Loader2, GitBranch, Pencil } from "lucide-solid" import { useConfig } from "../stores/preferences" import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" @@ -16,19 +16,13 @@ import { showAlertDialog } from "../stores/alerts" import { openSettings, settingsOpen } from "../stores/settings-screen" import { openExternalUrl } from "../lib/external-url" import { serverApi } from "../lib/api-client" -import { canOpenRemoteWindows } from "../lib/runtime-env" import { getExistingInstanceForFolder, updateProjectNameForFolder } from "../stores/instances" import { LocaleSelector } from "./locale-selector" -import { RemoteServerDialog } from "./remote-server-dialog" -import { useRemoteServerProfiles } from "../lib/hooks/use-remote-server-profiles" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad" const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945" -type HomeTab = "local" | "servers" - - interface FolderSelectionViewProps { onSelectFolder: (folder: string) => void onSelectExistingInstance: (instanceId: string, recentPath: string) => void @@ -43,7 +37,6 @@ const FolderSelectionView: Component = (props) => { removeRecentFolder, renameRecentFolderProject, } = useConfig() - const { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } = useRemoteServerProfiles() const { t } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [hoveredRecentActionPath, setHoveredRecentActionPath] = createSignal(null) @@ -59,19 +52,15 @@ const FolderSelectionView: Component = (props) => { const [cleanupCloneDestination, setCleanupCloneDestination] = createSignal(false) const [cloneDialogError, setCloneDialogError] = createSignal(null) const [isCloningRepository, setIsCloningRepository] = createSignal(false) - const [activeTab, setActiveTab] = createSignal("local") - const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false) let homeRootRef: HTMLDivElement | undefined let actionsColumnRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined const folders = () => recentFolders() - const serverList = () => remoteServers() const isLoading = () => Boolean(props.isLoading) - const canUseRemoteServerWindows = () => canOpenRemoteWindows() function getActiveListLength() { - return activeTab() === "local" ? folders().length : serverList().length + return folders().length } function scrollToIndex(index: number) { @@ -176,30 +165,10 @@ const FolderSelectionView: Component = (props) => { if (isLoading()) return const index = selectedIndex() - if (activeTab() === "local") { - const folder = folders()[index] - if (folder) { - handleFolderSelect(folder.path) - } - return - } - - const server = serverList()[index] - if (server) { - void connectSavedServer(server.id) - } + const folder = folders()[index] + if (folder) handleFolderSelect(folder.path) } - createEffect(() => { - activeTab() - if (!canUseRemoteServerWindows() && activeTab() !== "local") { - setActiveTab("local") - return - } - setSelectedIndex(0) - setFocusMode("recent") - }) - createEffect(() => { const length = getActiveListLength() if (length === 0) { @@ -342,11 +311,6 @@ const FolderSelectionView: Component = (props) => { } } - function openServerDialog() { - if (!canUseRemoteServerWindows()) return - setIsServerDialogOpen(true) - } - async function handleBrowse() { if (isLoading()) return setFocusMode("new") @@ -525,17 +489,15 @@ const FolderSelectionView: Component = (props) => { > - - - + - - - +
+
+
{t("folderSelection.recent.title")}
+

+ {t( + folders().length === 1 + ? "folderSelection.recent.subtitle.one" + : "folderSelection.recent.subtitle.other", + { count: folders().length }, + )} +

- 0} - fallback={ - -
-
- -
-

{t("folderSelection.servers.empty.title")}

-

{t("folderSelection.servers.empty.description")}

- -
-
- } - > -
(recentListRef = el)} - > - - {(server, index) => ( -
-
- - -
-
- )} -
-
-
- } - > 0} fallback={ @@ -897,7 +725,6 @@ const FolderSelectionView: Component = (props) => { -
@@ -954,17 +781,6 @@ const FolderSelectionView: Component = (props) => { - - - {/* OpenCode settings section */} @@ -1151,7 +967,6 @@ const FolderSelectionView: Component = (props) => { - ) } diff --git a/packages/ui/src/components/instance-tabs.tsx b/packages/ui/src/components/instance-tabs.tsx index 02b27f294..93fbcc479 100644 --- a/packages/ui/src/components/instance-tabs.tsx +++ b/packages/ui/src/components/instance-tabs.tsx @@ -21,7 +21,6 @@ import { type DeveloperModeState, } from "../lib/native/developer-mode" import { isOsNotificationSupportedSync } from "../lib/os-notifications" -import { canOpenRemoteWindows } from "../lib/runtime-env" import { getUnreadToastCountSignal, showToastNotification } from "../lib/notifications" import { useConfig } from "../stores/preferences" import { openSettings } from "../stores/settings-screen" @@ -361,16 +360,14 @@ const InstanceTabs: Component = (props) => { - - - + diff --git a/packages/ui/src/components/remote-access-overlay.tsx b/packages/ui/src/components/remote-access-overlay.tsx deleted file mode 100644 index 7b6a87253..000000000 --- a/packages/ui/src/components/remote-access-overlay.tsx +++ /dev/null @@ -1,520 +0,0 @@ -import { Dialog } from "@kobalte/core/dialog" -import { Switch } from "@kobalte/core/switch" -import { For, Show, createEffect, createMemo, createSignal } from "solid-js" -import { toDataURL } from "qrcode" -import { ChevronRight, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" -import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" -import { serverApi } from "../lib/api-client" -import { restartCli } from "../lib/native/cli" -import { serverSettings, setListeningMode } from "../stores/preferences" -import { showConfirmDialog } from "../stores/alerts" -import { getLogger } from "../lib/logger" -import { useI18n } from "../lib/i18n" -import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses" -const log = getLogger("actions") - - -interface RemoteAccessOverlayProps { - open: boolean - onClose: () => void -} - -export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { - const { t } = useI18n() - const [meta, setMeta] = createSignal(null) - const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null) - const [loading, setLoading] = createSignal(false) - const [applyingListeningMode, setApplyingListeningMode] = createSignal(false) - const [qrCodes, setQrCodes] = createSignal>({}) - const [expandedUrl, setExpandedUrl] = createSignal(null) - const [error, setError] = createSignal(null) - const [passwordFormOpen, setPasswordFormOpen] = createSignal(false) - const [passwordValue, setPasswordValue] = createSignal("") - const [passwordConfirm, setPasswordConfirm] = createSignal("") - const [passwordError, setPasswordError] = createSignal(null) - const [savingPassword, setSavingPassword] = createSignal(false) - const [showAllAddresses, setShowAllAddresses] = createSignal(false) - - const addresses = createMemo(() => meta()?.addresses ?? []) - const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) - const allowExternalConnections = createMemo(() => currentMode() === "all") - const displayAddresses = createMemo(() => { - const list = addresses() - if (!allowExternalConnections()) { - return { recommended: null, hidden: [] } - } - return splitRemoteAddresses(list) - }) - - const refreshMeta = async () => { - setLoading(true) - setError(null) - setPasswordError(null) - try { - const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) - setMeta(metaResult) - setAuthStatus(authResult) - setShowAllAddresses(false) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setLoading(false) - } - } - - createEffect(() => { - if (props.open) { - void refreshMeta() - } - }) - - const toggleExpanded = async (url: string) => { - if (expandedUrl() === url) { - setExpandedUrl(null) - return - } - setExpandedUrl(url) - if (!qrCodes()[url]) { - try { - const dataUrl = await toDataURL(url, { margin: 1, scale: 4 }) - setQrCodes((prev) => ({ ...prev, [url]: dataUrl })) - } catch (err) { - log.error("Failed to generate QR code", err) - } - } - } - - const handleAllowConnectionsChange = async (checked: boolean) => { - const allow = Boolean(checked) - const targetMode: "local" | "all" = allow ? "all" : "local" - if (targetMode === currentMode()) { - return - } - - if (applyingListeningMode()) { - return - } - - const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), { - title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"), - variant: "warning", - confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"), - cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"), - dismissible: false, - }) - - if (!confirmed) { - // Switch will revert automatically since `checked` is derived from store state - return - } - - setApplyingListeningMode(true) - setError(null) - try { - // Important: await the config patch before restart so Electron reads the updated mode from disk. - await setListeningMode(targetMode) - const restarted = await restartCli() - if (!restarted) { - setError(t("remoteAccess.restart.errorManual")) - } else { - setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setApplyingListeningMode(false) - } - - void refreshMeta() - } - - const handleOpenUrl = (url: string) => { - try { - window.open(url, "_blank", "noopener,noreferrer") - } catch (err) { - log.error("Failed to open URL", err) - } - } - - const handleSubmitPassword = async () => { - setPasswordError(null) - - const next = passwordValue() - const confirm = passwordConfirm() - - if (next.trim().length < 8) { - setPasswordError(t("remoteAccess.password.error.tooShort")) - return - } - - if (next !== confirm) { - setPasswordError(t("remoteAccess.password.error.mismatch")) - return - } - - setSavingPassword(true) - try { - const result = await serverApi.setServerPassword(next) - setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided }) - setPasswordValue("") - setPasswordConfirm("") - setPasswordFormOpen(false) - } catch (err) { - setPasswordError(err instanceof Error ? err.message : String(err)) - } finally { - setSavingPassword(false) - } - } - - return ( - { - if (!nextOpen) { - props.onClose() - } - }} - > - - -
- -
-
-

{t("remoteAccess.eyebrow")}

-

{t("remoteAccess.title")}

-

{t("remoteAccess.subtitle")}

-
- -
- -
-
-
-
- -
-

{t("remoteAccess.sections.listeningMode.label")}

-

{t("remoteAccess.sections.listeningMode.help")}

-
-
- -
- - { - void handleAllowConnectionsChange(nextChecked) - }} - disabled={loading() || applyingListeningMode()} - > - - - {allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")} - - -
- {t("remoteAccess.toggle.title")} - - {allowExternalConnections() ? t("remoteAccess.toggle.caption.all") : t("remoteAccess.toggle.caption.local")} - -
-
-

- {t("remoteAccess.toggle.note")} -

-
- -
-
-
- -
-

{t("remoteAccess.sections.serverPassword.label")}

-

{t("remoteAccess.sections.serverPassword.help")}

-
-
-
- - {t("remoteAccess.authStatus.unavailable")}
} - > -
-

- {t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })} -

-

- {authStatus()!.passwordUserProvided - ? t("remoteAccess.password.status.set") - : t("remoteAccess.password.status.unset")} -

- -
- -
- - -
- - setPasswordValue(event.currentTarget.value)} - placeholder={t("remoteAccess.password.form.placeholder")} - /> -
-
- - setPasswordConfirm(event.currentTarget.value)} - /> -
- - - {(message) =>
{message()}
} -
- -
- -
-
-
- - - -
- -
-
- -
-

{t("remoteAccess.sections.addresses.label")}

-

{t("remoteAccess.sections.addresses.help")}

-
-
-
- - {t("remoteAccess.addresses.loading")}
}> - {error()}}> - {t("remoteAccess.addresses.none")}}> -
- - {(url) => { - const value = () => url() - const expandedState = () => expandedUrl() === value() - const qr = () => qrCodes()[value()] - return ( -
-
-
-

{value()}

-

{t("remoteAccess.address.scope.loopback")}

-
-
- - -
-
- -
- -
-
-
- ) - }} -
- - {(addressAccessor) => { - const address = addressAccessor() - const url = address.remoteUrl - const expandedState = () => expandedUrl() === url - const qr = () => qrCodes()[url] - const scopeLabel = () => - address.scope === "external" - ? t("remoteAccess.address.scope.network") - : address.scope === "loopback" - ? t("remoteAccess.address.scope.loopback") - : t("remoteAccess.address.scope.internal") - - return ( -
-
-
-

{url}

-

- {address.family.toUpperCase()} - {scopeLabel()} - {address.ip} -

-
-
- - -
-
- -
- -
-
-
- ) - }} -
- - 0}> -
- - - -
- - {(address) => { - const url = address.remoteUrl - const expandedState = () => expandedUrl() === url - const qr = () => qrCodes()[url] - const scopeLabel = () => - address.scope === "external" - ? t("remoteAccess.address.scope.network") - : address.scope === "loopback" - ? t("remoteAccess.address.scope.loopback") - : t("remoteAccess.address.scope.internal") - return ( -
-
-
-

{url}

-

- {address.family.toUpperCase()} • {scopeLabel()} • {address.ip} -

-
-
- - -
-
- -
- -
-
-
- ) - }} -
-
-
-
-
-
-
-
- - - - - -
-
- ) -} diff --git a/packages/ui/src/components/remote-server-dialog.tsx b/packages/ui/src/components/remote-server-dialog.tsx deleted file mode 100644 index dc5509f37..000000000 --- a/packages/ui/src/components/remote-server-dialog.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Dialog } from "@kobalte/core/dialog" -import { Loader2 } from "lucide-solid" -import { createEffect, createSignal, Show, type Component } from "solid-js" -import { useI18n } from "../lib/i18n" -import type { RemoteServerInput } from "../lib/hooks/use-remote-server-profiles" - -interface RemoteServerDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - onSubmit: (input: RemoteServerInput, openWindow: boolean) => Promise -} - -export const RemoteServerDialog: Component = (props) => { - const { t } = useI18n() - const [name, setName] = createSignal("") - const [baseUrl, setBaseUrl] = createSignal("") - const [skipTlsVerify, setSkipTlsVerify] = createSignal(false) - const [error, setError] = createSignal(null) - const [busy, setBusy] = createSignal(false) - - createEffect(() => { - if (!props.open) return - setName("") - setBaseUrl("") - setSkipTlsVerify(false) - setError(null) - }) - - const submit = async (openWindow: boolean) => { - if (busy()) return - setBusy(true) - setError(null) - try { - await props.onSubmit({ name: name(), baseUrl: baseUrl(), skipTlsVerify: skipTlsVerify() }, openWindow) - props.onOpenChange(false) - } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : String(submitError)) - } finally { - setBusy(false) - } - } - - return ( - - - -
- -
- {t("folderSelection.servers.dialog.title")} - {t("folderSelection.servers.dialog.description")} -
- - - - {(message) =>

{message()}

}
-
- - - -
-
-
-
-
- ) -} diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx index 3dce3f2cd..80361a8c5 100644 --- a/packages/ui/src/components/settings-screen.tsx +++ b/packages/ui/src/components/settings-screen.tsx @@ -21,10 +21,8 @@ import { ProvidersSettingsSection } from "./settings/providers-settings-section" import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { AdvancedSettingsSection } from "./settings/advanced-settings-section" import { ConfigFilesSettingsSection } from "./settings/config-files-settings-section" -import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" -import { SavedRemoteServersCard } from "./settings/saved-remote-servers-card" +import { RemoteControlSettingsSection } from "./settings/remote-control-settings-section" import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" -import { canOpenRemoteWindows } from "../lib/runtime-env" import { confirmSettingsDiscard } from "../stores/settings-dirty-guard" import { NativeTitlebar } from "./native-titlebar" @@ -58,9 +56,7 @@ export const SettingsScreen: Component = (props) => { { id: "advanced", icon: Settings, label: t("settings.nav.advanced") }, { id: "info", icon: Info, label: t("settings.nav.info") }, ] - if (props.standalone || canOpenRemoteWindows()) { - items.splice(4, 0, { id: "remote", icon: MonitorUp, label: t("settings.nav.remote") }) - } + items.splice(4, 0, { id: "remote", icon: MonitorUp, label: t("settings.nav.remote") }) return items }) @@ -75,12 +71,11 @@ export const SettingsScreen: Component = (props) => { case "speech": return case "remote": - return props.standalone || canOpenRemoteWindows() ? ( + return (
- - +
- ) : + ) case "opencode": return case "providers": diff --git a/packages/ui/src/components/settings/remote-access-settings-section.tsx b/packages/ui/src/components/settings/remote-access-settings-section.tsx deleted file mode 100644 index 4c9fa3730..000000000 --- a/packages/ui/src/components/settings/remote-access-settings-section.tsx +++ /dev/null @@ -1,487 +0,0 @@ -import { Switch } from "@kobalte/core/switch" -import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js" -import { toDataURL } from "qrcode" -import { ChevronRight, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" -import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types" -import { serverApi } from "../../lib/api-client" -import { restartCli } from "../../lib/native/cli" -import { serverSettings, setListeningMode } from "../../stores/preferences" -import { showConfirmDialog } from "../../stores/alerts" -import { getLogger } from "../../lib/logger" -import { useI18n } from "../../lib/i18n" -import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses" - -const log = getLogger("actions") - -export const RemoteAccessSettingsSection: Component = () => { - const { t } = useI18n() - const [meta, setMeta] = createSignal(null) - const [authStatus, setAuthStatus] = createSignal<{ - authenticated: boolean - username?: string - passwordUserProvided?: boolean - } | null>(null) - const [loading, setLoading] = createSignal(false) - const [applyingListeningMode, setApplyingListeningMode] = createSignal(false) - const [qrCodes, setQrCodes] = createSignal>({}) - const [expandedUrl, setExpandedUrl] = createSignal(null) - const [error, setError] = createSignal(null) - const [passwordFormOpen, setPasswordFormOpen] = createSignal(false) - const [passwordValue, setPasswordValue] = createSignal("") - const [passwordConfirm, setPasswordConfirm] = createSignal("") - const [passwordError, setPasswordError] = createSignal(null) - const [savingPassword, setSavingPassword] = createSignal(false) - const [showAllAddresses, setShowAllAddresses] = createSignal(false) - - const addresses = createMemo(() => meta()?.addresses ?? []) - const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) - const allowExternalConnections = createMemo(() => currentMode() === "all") - const displayAddresses = createMemo(() => { - const list = addresses() - if (!allowExternalConnections()) return { recommended: null, hidden: [] } - return splitRemoteAddresses(list) - }) - - const refreshMeta = async () => { - setLoading(true) - setError(null) - setPasswordError(null) - try { - const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) - setMeta(metaResult) - setAuthStatus(authResult) - setShowAllAddresses(false) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setLoading(false) - } - } - - onMount(() => { - void refreshMeta() - }) - - const toggleExpanded = async (url: string) => { - if (expandedUrl() === url) { - setExpandedUrl(null) - return - } - setExpandedUrl(url) - if (!qrCodes()[url]) { - try { - const dataUrl = await toDataURL(url, { margin: 1, scale: 4 }) - setQrCodes((prev) => ({ ...prev, [url]: dataUrl })) - } catch (err) { - log.error("Failed to generate QR code", err) - } - } - } - - const handleAllowConnectionsChange = async (checked: boolean) => { - const targetMode: "local" | "all" = checked ? "all" : "local" - if (targetMode === currentMode() || applyingListeningMode()) return - - const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), { - title: checked - ? t("remoteAccess.listeningMode.restartConfirm.title.all") - : t("remoteAccess.listeningMode.restartConfirm.title.local"), - variant: "warning", - confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"), - cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"), - dismissible: false, - }) - - if (!confirmed) return - - setApplyingListeningMode(true) - setError(null) - try { - await setListeningMode(targetMode) - const restarted = await restartCli() - if (!restarted) { - setError(t("remoteAccess.restart.errorManual")) - } else { - setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setApplyingListeningMode(false) - } - - void refreshMeta() - } - - const handleOpenUrl = (url: string) => { - try { - window.open(url, "_blank", "noopener,noreferrer") - } catch (err) { - log.error("Failed to open URL", err) - } - } - - const handleSubmitPassword = async () => { - setPasswordError(null) - - const next = passwordValue() - const confirm = passwordConfirm() - if (next.trim().length < 8) { - setPasswordError(t("remoteAccess.password.error.tooShort")) - return - } - if (next !== confirm) { - setPasswordError(t("remoteAccess.password.error.mismatch")) - return - } - - setSavingPassword(true) - try { - const result = await serverApi.setServerPassword(next) - setAuthStatus({ - authenticated: true, - username: result.username, - passwordUserProvided: result.passwordUserProvided, - }) - setPasswordValue("") - setPasswordConfirm("") - setPasswordFormOpen(false) - } catch (err) { - setPasswordError(err instanceof Error ? err.message : String(err)) - } finally { - setSavingPassword(false) - } - } - - return ( -
-
-
-
- -
-

{t("remoteAccess.sections.listeningMode.label")}

-

{t("remoteAccess.sections.listeningMode.help")}

-
-
-
- {t("settings.scope.server")} - -
-
- - void handleAllowConnectionsChange(nextChecked)} - disabled={loading() || applyingListeningMode()} - > - - - - {allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")} - - - -
- {t("remoteAccess.toggle.title")} - - {allowExternalConnections() - ? t("remoteAccess.toggle.caption.all") - : t("remoteAccess.toggle.caption.local")} - -
-
- -

{t("remoteAccess.toggle.note")}

-
- -
-
-
- -
-

{t("remoteAccess.sections.serverPassword.label")}

-

{t("remoteAccess.sections.serverPassword.help")}

-
-
- {t("settings.scope.server")} -
- - {t("remoteAccess.authStatus.unavailable")}
} - > -
-
-
-

{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}

-

- {authStatus()!.passwordUserProvided - ? t("remoteAccess.password.status.set") - : t("remoteAccess.password.status.unset")} -

-
- -
- -
-
- - -
- - setPasswordValue(event.currentTarget.value)} - placeholder={t("remoteAccess.password.form.placeholder")} - /> -
-
- - setPasswordConfirm(event.currentTarget.value)} - /> -
- - - {(message) =>
{message()}
} -
- -
- -
-
-
- -
- -
-
-
- -
-

{t("remoteAccess.sections.addresses.label")}

-

{t("remoteAccess.sections.addresses.help")}

-
-
- {t("settings.scope.server")} -
- - {t("remoteAccess.addresses.loading")}
}> - {error()}}> - {t("remoteAccess.addresses.none")}} - > -
- - {(url) => { - const value = () => url() - const expandedState = () => expandedUrl() === value() - const qr = () => qrCodes()[value()] - return ( -
-
-
-

{value()}

-

{t("remoteAccess.address.scope.loopback")}

-
-
- - -
-
- -
- -
-
-
- ) - }} -
- - - {(addressAccessor) => { - const address = addressAccessor() - const url = address.remoteUrl - const expandedState = () => expandedUrl() === url - const qr = () => qrCodes()[url] - const scopeLabel = () => - address.scope === "external" - ? t("remoteAccess.address.scope.network") - : address.scope === "loopback" - ? t("remoteAccess.address.scope.loopback") - : t("remoteAccess.address.scope.internal") - - return ( -
-
-
-

{url}

-

- {address.family.toUpperCase()} - {scopeLabel()} - {address.ip} -

-
-
- - -
-
- -
- -
-
-
- ) - }} -
- - 0}> -
- - - -
- - {(address) => { - const url = address.remoteUrl - const expandedState = () => expandedUrl() === url - const qr = () => qrCodes()[url] - const scopeLabel = () => - address.scope === "external" - ? t("remoteAccess.address.scope.network") - : address.scope === "loopback" - ? t("remoteAccess.address.scope.loopback") - : t("remoteAccess.address.scope.internal") - - return ( -
-
-
-

{url}

-

- {address.family.toUpperCase()} - {scopeLabel()} - {address.ip} -

-
-
- - -
-
- -
- -
-
-
- ) - }} -
-
-
-
-
-
-
-
- - - - ) -} diff --git a/packages/ui/src/components/settings/remote-control-settings-section.tsx b/packages/ui/src/components/settings/remote-control-settings-section.tsx new file mode 100644 index 000000000..b0e070027 --- /dev/null +++ b/packages/ui/src/components/settings/remote-control-settings-section.tsx @@ -0,0 +1,229 @@ +import { For, Show, createSignal, onMount, type Component } from "solid-js" +import { toDataURL } from "qrcode" +import { Copy, Link2, Loader2, MonitorUp, RefreshCw, ShieldCheck, Unplug } from "lucide-solid" +import type { RemoteControlDevice, RemoteControlPairing, RemoteControlStatus } from "../../../../server/src/api-types" +import { serverApi } from "../../lib/api-client" +import { useI18n } from "../../lib/i18n" +import { getLogger } from "../../lib/logger" + +const log = getLogger("actions") + +export const RemoteControlSettingsSection: Component = () => { + const { t } = useI18n() + const [status, setStatus] = createSignal(null) + const [pairing, setPairing] = createSignal(null) + const [qrCode, setQrCode] = createSignal(null) + const [devices, setDevices] = createSignal([]) + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(null) + const [copied, setCopied] = createSignal(false) + + const refresh = async () => { + setLoading(true) + setError(null) + try { + const next = await serverApi.fetchRemoteControlStatus() + setStatus(next) + if (next.manageable && next.enabled && next.state === "connected") { + const result = await serverApi.fetchRemoteControlDevices() + setDevices(result.devices) + } else { + setDevices([]) + } + } catch (cause) { + setError(message(cause)) + } finally { + setLoading(false) + } + } + + const showPairing = async (next: RemoteControlPairing) => { + setPairing(next) + setQrCode(null) + try { + setQrCode(await toDataURL(next.url, { margin: 1, scale: 5 })) + } catch (cause) { + log.warn("Failed to generate Remote Control QR code", cause) + } + } + + const start = async () => { + setLoading(true) + setError(null) + try { + const result = await serverApi.startRemoteControl() + setStatus(result.status) + await showPairing(result.pairing) + const deviceResult = await serverApi.fetchRemoteControlDevices() + setDevices(deviceResult.devices) + } catch (cause) { + setError(message(cause)) + await refresh() + } finally { + setLoading(false) + } + } + + const stop = async () => { + setLoading(true) + setError(null) + try { + setStatus(await serverApi.stopRemoteControl()) + setPairing(null) + setQrCode(null) + setDevices([]) + } catch (cause) { + setError(message(cause)) + } finally { + setLoading(false) + } + } + + const createPairing = async () => { + setLoading(true) + setError(null) + try { + await showPairing(await serverApi.createRemoteControlPairing()) + } catch (cause) { + setError(message(cause)) + } finally { + setLoading(false) + } + } + + const copyPairing = async () => { + const url = pairing()?.url + if (!url) return + try { + await navigator.clipboard.writeText(url) + setCopied(true) + setTimeout(() => setCopied(false), 2_000) + } catch (cause) { + setError(message(cause)) + } + } + + const revoke = async (id: string) => { + setError(null) + try { + await serverApi.revokeRemoteControlDevice(id) + setDevices((current) => current.filter((device) => device.id !== id)) + } catch (cause) { + setError(message(cause)) + } + } + + onMount(() => void refresh()) + + return ( +
+
+
+
+ +
+

{t("remoteControl.title")}

+

{t("remoteControl.description")}

+
+
+ +
+ +
+
+ + +
+ void start()} disabled={loading()}> + + {t("remoteControl.start")} + + } + > + + + +
+
+ + {(value) =>
{value()}
}
+
+ + + {(value) => ( +
+
+

{t("remoteControl.pairing.title")}

+

{t("remoteControl.pairing.description")}

+
+ }> + {(source) => {t("remoteControl.pairing.qrAlt")}} + + {value().url} + +

{t("remoteControl.pairing.expires", { time: new Date(value().expiresAt).toLocaleTimeString() })}

+
+ )} +
+ + +
+
+
+ +
+

{t("remoteControl.devices.title")}

+

{t("remoteControl.devices.description")}

+
+
+
+ 0} fallback={
{t("remoteControl.devices.empty")}
}> +
+ + {(device) => ( +
+
+ {device.name} +

{t("remoteControl.devices.lastSeen", { time: new Date(device.lastSeenAt).toLocaleString() })}

+
+ +
+ )} +
+
+
+
+
+
+ ) +} + +function message(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause) +} diff --git a/packages/ui/src/components/settings/saved-remote-servers-card.tsx b/packages/ui/src/components/settings/saved-remote-servers-card.tsx deleted file mode 100644 index 095457b81..000000000 --- a/packages/ui/src/components/settings/saved-remote-servers-card.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Globe, Loader2, Plus, Trash2 } from "lucide-solid" -import { createSignal, For, Show, type Component } from "solid-js" -import { useI18n } from "../../lib/i18n" -import { useRemoteServerProfiles } from "../../lib/hooks/use-remote-server-profiles" -import { RemoteServerDialog } from "../remote-server-dialog" - -export const SavedRemoteServersCard: Component = () => { - const { t } = useI18n() - const { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } = useRemoteServerProfiles() - const [dialogOpen, setDialogOpen] = createSignal(false) - - return ( - <> -
-
-
- -
-

{t("folderSelection.tabs.servers")}

-

{t("folderSelection.servers.empty.description")}

-
-
- -
-
- 0} fallback={
{t("folderSelection.servers.empty.title")}
}> - - {(server) => ( -
-
-
{server.name}
-
{server.baseUrl}
-
-
- - -
-
- )} -
-
-
-
- - - ) -} diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 435660c28..4d42ab490 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -17,10 +17,10 @@ import type { PreviewSession, ProviderUsageResponse, ServerMeta, - RemoteProxySessionCreateRequest, - RemoteProxySessionCreateResponse, - RemoteServerProbeRequest, - RemoteServerProbeResponse, + RemoteControlDevice, + RemoteControlPairing, + RemoteControlStartResponse, + RemoteControlStatus, YoloStateResponse, WorkspaceCloneRequest, WorkspaceCloneResponse, @@ -262,20 +262,23 @@ export const serverApi = { fetchServerMeta(): Promise { return request("/api/meta") }, - probeRemoteServer(payload: RemoteServerProbeRequest): Promise { - return request("/api/remote-servers/probe", { - method: "POST", - body: JSON.stringify(payload), - }) + fetchRemoteControlStatus(): Promise { + return request("/api/remote-control/status") }, - createRemoteProxySession(payload: RemoteProxySessionCreateRequest): Promise { - return request("/api/remote-proxy/sessions", { - method: "POST", - body: JSON.stringify(payload), - }) + startRemoteControl(): Promise { + return request("/api/remote-control/start", { method: "POST" }) + }, + stopRemoteControl(): Promise { + return request("/api/remote-control", { method: "DELETE" }) + }, + createRemoteControlPairing(): Promise { + return request("/api/remote-control/pairings", { method: "POST" }) + }, + fetchRemoteControlDevices(): Promise<{ devices: RemoteControlDevice[] }> { + return request<{ devices: RemoteControlDevice[] }>("/api/remote-control/devices") }, - deleteRemoteProxySession(id: string): Promise { - return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }) + revokeRemoteControlDevice(id: string): Promise { + return request(`/api/remote-control/devices/${encodeURIComponent(id)}`, { method: "DELETE" }) }, fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> { return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status") diff --git a/packages/ui/src/lib/hooks/use-remote-server-profiles.ts b/packages/ui/src/lib/hooks/use-remote-server-profiles.ts deleted file mode 100644 index 1fea22b11..000000000 --- a/packages/ui/src/lib/hooks/use-remote-server-profiles.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createSignal } from "solid-js" -import { serverApi } from "../api-client" -import { useI18n } from "../i18n" -import { openRemoteServerWindow } from "../native/remote-window" -import { canOpenRemoteWindows, isTauriHost } from "../runtime-env" -import { showAlertDialog } from "../../stores/alerts" -import { useConfig } from "../../stores/preferences" - -export type RemoteServerInput = { - id?: string - name: string - baseUrl: string - skipTlsVerify: boolean -} - -export function useRemoteServerProfiles() { - const { t } = useI18n() - const { remoteServers, saveRemoteServerProfile, markRemoteServerConnected, removeRemoteServerProfile } = useConfig() - const [connectingServerId, setConnectingServerId] = createSignal(null) - - const saveServer = async (input: RemoteServerInput, openWindow: boolean) => { - if (openWindow && !canOpenRemoteWindows()) { - throw new Error("Remote server windows can only be opened from a local desktop window") - } - - const name = input.name.trim() - const baseUrl = input.baseUrl.trim() - if (!name || !baseUrl) throw new Error(t("folderSelection.servers.dialog.errorRequired")) - - const probe = await serverApi.probeRemoteServer({ baseUrl, skipTlsVerify: input.skipTlsVerify }) - if (!probe.ok) throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect")) - - const profile = await saveRemoteServerProfile({ - id: input.id, - name, - baseUrl: probe.normalizedUrl, - skipTlsVerify: input.skipTlsVerify, - }) - - if (openWindow) { - const proxySession = - isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://") - ? await serverApi.createRemoteProxySession({ baseUrl: profile.baseUrl, skipTlsVerify: true }) - : undefined - - try { - await openRemoteServerWindow(profile, proxySession?.windowUrl, proxySession?.sessionId) - } catch (error) { - if (proxySession) void serverApi.deleteRemoteProxySession(proxySession.sessionId).catch(() => {}) - throw error - } - await markRemoteServerConnected(profile.id) - } - - return profile - } - - const connectSavedServer = async (id: string) => { - if (!canOpenRemoteWindows() || connectingServerId()) return - const target = remoteServers().find((server) => server.id === id) - if (!target) return - - setConnectingServerId(id) - try { - await saveServer(target, true) - } catch (error) { - showAlertDialog(error instanceof Error ? error.message : String(error), { - title: t("folderSelection.servers.errorTitle"), - variant: "warning", - }) - } finally { - setConnectingServerId(null) - } - } - - return { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } -} diff --git a/packages/ui/src/lib/i18n/messages/de/folderSelection.ts b/packages/ui/src/lib/i18n/messages/de/folderSelection.ts index ac1d18079..83f2dc2ff 100644 --- a/packages/ui/src/lib/i18n/messages/de/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/de/folderSelection.ts @@ -41,7 +41,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "Repository-URL und Zielordner sind erforderlich.", "folderSelection.actions.title": "Ordner öffnen oder Server verbinden", "folderSelection.actions.subtitle": "Lokalen Ordner öffnen oder mit einem CodeNomad-Server verbinden", - "folderSelection.actions.connectButton": "CodeNomad-Server verbinden", "folderSelection.advancedSettings": "Erweiterte Einstellungen", "folderSelection.opencode": "OpenCode", @@ -63,36 +62,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Wählen Sie einen Arbeitsbereich aus, um mit dem Codieren zu beginnen.", "folderSelection.tabs.local": "Lokale Ordner", - "folderSelection.tabs.servers": "Server", - "folderSelection.servers.title": "Gespeicherte Server", - "folderSelection.servers.subtitle": "Einen gespeicherten CodeNomad-Remote-Server in einem neuen Fenster öffnen", - "folderSelection.servers.count": "{count} Server", - "folderSelection.servers.empty.title": "Keine gespeicherten Server", - "folderSelection.servers.empty.description": "Fügen Sie einen Remote-Server hinzu, um sich von diesem Gerät aus schnell wieder zu verbinden", - "folderSelection.servers.connectTitle": "Mit Server verbinden", - "folderSelection.servers.connectSubtitle": "Einen CodeNomad-Remote-Server speichern und in einem neuen Fenster öffnen", - "folderSelection.servers.connectButton": "Mit Server verbinden", - "folderSelection.servers.remove": "Gespeicherten Server entfernen", - "folderSelection.servers.skipTls": "Selbstsigniertes TLS", - "folderSelection.servers.errorTitle": "Remote-Verbindung fehlgeschlagen", - "folderSelection.servers.dialog.title": "Mit Server verbinden", - "folderSelection.servers.dialog.description": "Fügen Sie einen CodeNomad-Remote-Server hinzu und öffnen Sie ihn optional sofort.", - "folderSelection.servers.dialog.name": "Servername", - "folderSelection.servers.dialog.namePlaceholder": "Produktionsserver", - "folderSelection.servers.dialog.url": "Server-URL", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.beispiel.de", - "folderSelection.servers.dialog.skipTls": "TLS-Verifizierung für selbstsignierte Zertifikate überspringen.", - "folderSelection.servers.dialog.cancel": "Abbrechen", - "folderSelection.servers.dialog.save": "Speichern", - "folderSelection.servers.dialog.connect": "Verbinden", - "folderSelection.servers.dialog.connecting": "Verbindung wird hergestellt...", - "folderSelection.servers.dialog.errorRequired": "Servername und URL sind erforderlich.", - "folderSelection.servers.dialog.errorConnect": "Verbindung zum Remote-Server konnte nicht hergestellt werden.", - "folderSelection.servers.certificateInstall.title": "Lokales Zertifikat installieren", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad muss ein lokales Zertifikat installieren, um selbstsignierte HTTPS-Remote-Fenster zu öffnen. Dieses Zertifikat wird nur für den lokalen Desktop-Proxy-Verkehr auf Ihrem Rechner verwendet. Ihr Betriebssystem zeigt danach möglicherweise eine zweite Zertifikatsabfrage an.", - "folderSelection.servers.certificateInstall.confirmLabel": "Weiter", - "folderSelection.servers.certificateInstall.cancelLabel": "Abbrechen", - "folderSelection.servers.certificateInstall.cancelled": "CodeNomad benötigt das Vertrauen in das lokale Zertifikat, bevor es selbstsignierte HTTPS-Remote-Fenster öffnen kann.", "folderSelection.sidecars.button": "SideCar öffnen", "projectRenameDialog.title": "Arbeitsbereich umbenennen", diff --git a/packages/ui/src/lib/i18n/messages/de/index.ts b/packages/ui/src/lib/i18n/messages/de/index.ts index b6d4e4c1e..3321544f3 100644 --- a/packages/ui/src/lib/i18n/messages/de/index.ts +++ b/packages/ui/src/lib/i18n/messages/de/index.ts @@ -9,7 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -31,6 +31,6 @@ export const deMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, - remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/de/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/de/remoteAccess.ts deleted file mode 100644 index cd0fc0192..000000000 --- a/packages/ui/src/lib/i18n/messages/de/remoteAccess.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "Remote-Übergabe", - "remoteAccess.title": "Remote mit CodeNomad verbinden", - "remoteAccess.subtitle": "Verwenden Sie die folgenden Adressen, um CodeNomad von einem anderen Gerät aus zu öffnen.", - "remoteAccess.close": "Remote-Zugriff schließen", - "remoteAccess.refresh": "Aktualisieren", - - "remoteAccess.sections.listeningMode.label": "Abhörmodus (Listening mode)", - "remoteAccess.sections.listeningMode.help": "Remote-Übergaben erlauben oder einschränken, indem an alle Schnittstellen oder nur an localhost gebunden wird.", - "remoteAccess.toggle.on": "An", - "remoteAccess.toggle.off": "Aus", - "remoteAccess.toggle.title": "Verbindungen von anderen IPs zulassen", - "remoteAccess.toggle.caption.all": "Bindung an 0.0.0.0", - "remoteAccess.toggle.caption.local": "Bindung an 127.0.0.1", - "remoteAccess.toggle.note": "Das Ändern dieser Einstellung erfordert einen Neustart und stoppt vorübergehend alle aktiven Instanzen. Teilen Sie die unten stehenden Adressen nach dem Neustart des Servers.", - "remoteAccess.listeningMode.restartConfirm.message": "Neustart zum Übernehmen des Abhörmodus? Dies stoppt alle laufenden Instanzen.", - "remoteAccess.listeningMode.restartConfirm.title.all": "Für andere Geräte öffnen", - "remoteAccess.listeningMode.restartConfirm.title.local": "Auf dieses Gerät beschränken", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Jetzt neu starten", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Abbrechen", - "remoteAccess.restart.errorManual": "Automatischer Neustart nicht möglich. Bitte starten Sie die App manuell neu.", - - "remoteAccess.sections.serverPassword.label": "Server-Passwort", - "remoteAccess.sections.serverPassword.help": "Remote-Übergaben erfordern ein Passwort. Legen Sie ein Passwort fest, um Anmeldungen von anderen Geräten zu ermöglichen.", - "remoteAccess.authStatus.unavailable": "Authentifizierungsstatus nicht verfügbar.", - "remoteAccess.username": "Benutzername: {username}", - "remoteAccess.password.status.set": "Ein Passwort für den Remote-Zugriff ist festgelegt.", - "remoteAccess.password.status.unset": "Noch kein Passwort festgelegt. Legen Sie eines fest, um Remote-Anmeldungen zu ermöglichen.", - "remoteAccess.password.actions.cancel": "Abbrechen", - "remoteAccess.password.actions.change": "Passwort ändern", - "remoteAccess.password.actions.set": "Passwort festlegen", - "remoteAccess.password.form.newPassword": "Neues Passwort", - "remoteAccess.password.form.confirmPassword": "Passwort bestätigen", - "remoteAccess.password.form.placeholder": "Mindestens 8 Zeichen", - "remoteAccess.password.error.tooShort": "Das Passwort muss mindestens 8 Zeichen lang sein.", - "remoteAccess.password.error.mismatch": "Die Passwörter stimmen nicht überein.", - "remoteAccess.password.save.saving": "Wird gespeichert...", - "remoteAccess.password.save.label": "Passwort speichern", - - "remoteAccess.sections.addresses.label": "Erreichbare Adressen", - "remoteAccess.sections.addresses.help": "Von einem anderen Gerät aus starten oder scannen, um die Steuerung zu übernehmen.", - "remoteAccess.addresses.loading": "Adressen werden geladen...", - "remoteAccess.addresses.none": "Noch keine Adressen verfügbar.", - "remoteAccess.addresses.actions.showOther": "{count} weitere Adressen anzeigen", - "remoteAccess.addresses.actions.hideOther": "Andere Adressen ausblenden", - "remoteAccess.address.scope.network": "Netzwerk", - "remoteAccess.address.scope.loopback": "Loopback", - "remoteAccess.address.scope.internal": "Intern", - "remoteAccess.address.open": "Öffnen", - "remoteAccess.address.showQr": "QR anzeigen", - "remoteAccess.address.hideQr": "QR ausblenden", - "remoteAccess.address.qrAlt": "QR für {url}", -} as const diff --git a/packages/ui/src/lib/i18n/messages/de/remoteControl.ts b/packages/ui/src/lib/i18n/messages/de/remoteControl.ts new file mode 100644 index 000000000..2b7d0536d --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/de/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Fernsteuerung", + "remoteControl.description": "Lokale CodeNomad-Sitzungen ohne offene Netzwerkports auf einem anderen Gerät fortsetzen.", + "remoteControl.refresh": "Aktualisieren", + "remoteControl.state.stopped": "Fernsteuerung ist aus", + "remoteControl.state.connecting": "Verbindung zum Relay…", + "remoteControl.state.connected": "Bereit für Remote-Verbindungen", + "remoteControl.state.reconnecting": "Verbindung wird wiederhergestellt…", + "remoteControl.state.error": "Relay-Verbindung fehlgeschlagen", + "remoteControl.status.localOnly": "CodeNomad ist nur auf diesem Gerät verfügbar.", + "remoteControl.status.hostOnly": "Verwalte Remote Control auf dem CodeNomad-Host.", + "remoteControl.start": "Fernsteuerung starten", + "remoteControl.newLink": "Neuen Kopplungslink erstellen", + "remoteControl.stop": "Fernsteuerung beenden", + "remoteControl.pairing.title": "Weiteres Gerät verbinden", + "remoteControl.pairing.description": "QR-Code scannen oder den einmaligen Link auf dem anderen Gerät öffnen.", + "remoteControl.pairing.qrAlt": "QR-Code zur Kopplung der Fernsteuerung", + "remoteControl.pairing.copy": "Link kopieren", + "remoteControl.pairing.copied": "Kopiert", + "remoteControl.pairing.expires": "Dieser Link läuft um {time} ab.", + "remoteControl.devices.title": "Gekoppelte Geräte", + "remoteControl.devices.description": "Diese Geräte können auf diesen CodeNomad-Host zugreifen, solange die Fernsteuerung läuft.", + "remoteControl.devices.empty": "Noch keine Geräte gekoppelt.", + "remoteControl.devices.lastSeen": "Zuletzt gesehen: {time}", + "remoteControl.devices.revoke": "Widerrufen", +} as const diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 54150ef4c..6b9011a2b 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Lange Assistentenantworten anhalten", "settings.behavior.holdLongAssistantReplies.subtitle": "Automatisches Folgen beenden, wenn eine laufende Antwort über das Ansichtsfenster hinausgeht.", "settings.nav.notifications": "Benachrichtigungen", - "settings.nav.remote": "Remote-Zugriff", + "settings.nav.remote": "Fernsteuerung", "settings.nav.speech": "Sprache", "settings.nav.providers": "Anbieter", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Benachrichtigungen aktiviert", "settings.notifications.status.disabled": "Benachrichtigungen deaktiviert", "settings.notifications.status.unsupported": "Benachrichtigungen nicht unterstützt", - "settings.section.remote.title": "Remote-Zugriff", - "settings.section.remote.subtitle": "Überprüfen Sie, wie dieser Server in Ihrem Netzwerk freigegeben ist, und sichern Sie die Zugangsdaten.", + "settings.section.remote.title": "Fernsteuerung", + "settings.section.remote.subtitle": "Sitzungen dieses Geräts sicher über das ausgehende Relay auf einem anderen Gerät fortsetzen.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Wählen Sie die OpenCode-Binärdatei und Umgebung für neue Instanzen.", "settings.opencode.runtime.title": "Laufzeit", diff --git a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts index 282d2b25f..7b184191c 100644 --- a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts @@ -41,7 +41,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "Repository URL and destination folder are required.", "folderSelection.actions.title": "Open Folder or Connect Server", "folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server", - "folderSelection.actions.connectButton": "Connect CodeNomad Server", "folderSelection.advancedSettings": "Advanced Settings", "folderSelection.opencode": "OpenCode", @@ -63,36 +62,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Select workspace to start coding.", "folderSelection.tabs.local": "Local Folders", - "folderSelection.tabs.servers": "Servers", - "folderSelection.servers.title": "Saved Servers", - "folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window", - "folderSelection.servers.count": "{count} Servers", - "folderSelection.servers.empty.title": "No Saved Servers", - "folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device", - "folderSelection.servers.connectTitle": "Connect to Server", - "folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window", - "folderSelection.servers.connectButton": "Connect to Server", - "folderSelection.servers.remove": "Remove saved server", - "folderSelection.servers.skipTls": "Self-signed TLS", - "folderSelection.servers.errorTitle": "Remote Connection Failed", - "folderSelection.servers.dialog.title": "Connect to Server", - "folderSelection.servers.dialog.description": "Add a remote CodeNomad server and optionally open it right away.", - "folderSelection.servers.dialog.name": "Server name", - "folderSelection.servers.dialog.namePlaceholder": "Production Server", - "folderSelection.servers.dialog.url": "Server URL", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", - "folderSelection.servers.dialog.skipTls": "Skip TLS verification for self-signed certificates.", - "folderSelection.servers.dialog.cancel": "Cancel", - "folderSelection.servers.dialog.save": "Save", - "folderSelection.servers.dialog.connect": "Connect", - "folderSelection.servers.dialog.connecting": "Connecting...", - "folderSelection.servers.dialog.errorRequired": "Server name and URL are required.", - "folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.", - "folderSelection.servers.certificateInstall.title": "Install Local Certificate", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.", - "folderSelection.servers.certificateInstall.confirmLabel": "Continue", - "folderSelection.servers.certificateInstall.cancelLabel": "Cancel", - "folderSelection.servers.certificateInstall.cancelled": "CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows.", "folderSelection.sidecars.button": "Open SideCar", "projectRenameDialog.title": "Rename workspace", diff --git a/packages/ui/src/lib/i18n/messages/en/index.ts b/packages/ui/src/lib/i18n/messages/en/index.ts index a7b1ef6ae..6b4e3e6a8 100644 --- a/packages/ui/src/lib/i18n/messages/en/index.ts +++ b/packages/ui/src/lib/i18n/messages/en/index.ts @@ -9,7 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -31,6 +31,6 @@ export const enMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, - remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts deleted file mode 100644 index cad9f855d..000000000 --- a/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "Remote handover", - "remoteAccess.title": "Connect to CodeNomad remotely", - "remoteAccess.subtitle": "Use the addresses below to open CodeNomad from another device.", - "remoteAccess.close": "Close remote access", - "remoteAccess.refresh": "Refresh", - - "remoteAccess.sections.listeningMode.label": "Listening mode", - "remoteAccess.sections.listeningMode.help": "Allow or limit remote handovers by binding to all interfaces or just localhost.", - "remoteAccess.toggle.on": "On", - "remoteAccess.toggle.off": "Off", - "remoteAccess.toggle.title": "Allow connections from other IPs", - "remoteAccess.toggle.caption.all": "Binding to 0.0.0.0", - "remoteAccess.toggle.caption.local": "Binding to 127.0.0.1", - "remoteAccess.toggle.note": "Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the server restarts.", - "remoteAccess.listeningMode.restartConfirm.message": "Restart to apply listening mode? This will stop all running instances.", - "remoteAccess.listeningMode.restartConfirm.title.all": "Open to other devices", - "remoteAccess.listeningMode.restartConfirm.title.local": "Limit to this device", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Restart now", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Cancel", - "remoteAccess.restart.errorManual": "Unable to restart automatically. Please restart the app to apply the change.", - - "remoteAccess.sections.serverPassword.label": "Server password", - "remoteAccess.sections.serverPassword.help": "Remote handovers require a password. Set a memorable one to enable logins from other devices.", - "remoteAccess.authStatus.unavailable": "Authentication status unavailable.", - "remoteAccess.username": "Username: {username}", - "remoteAccess.password.status.set": "A password is set for remote access.", - "remoteAccess.password.status.unset": "No memorable password is set yet. Set one to allow remote handover logins.", - "remoteAccess.password.actions.cancel": "Cancel", - "remoteAccess.password.actions.change": "Change password", - "remoteAccess.password.actions.set": "Set password", - "remoteAccess.password.form.newPassword": "New password", - "remoteAccess.password.form.confirmPassword": "Confirm password", - "remoteAccess.password.form.placeholder": "At least 8 characters", - "remoteAccess.password.error.tooShort": "Password must be at least 8 characters.", - "remoteAccess.password.error.mismatch": "Passwords do not match.", - "remoteAccess.password.save.saving": "Saving…", - "remoteAccess.password.save.label": "Save password", - - "remoteAccess.sections.addresses.label": "Reachable addresses", - "remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.", - "remoteAccess.addresses.loading": "Loading addresses…", - "remoteAccess.addresses.none": "No addresses available yet.", - "remoteAccess.addresses.actions.showOther": "Show {count} other addresses", - "remoteAccess.addresses.actions.hideOther": "Hide other addresses", - "remoteAccess.address.scope.network": "Network", - "remoteAccess.address.scope.loopback": "Loopback", - "remoteAccess.address.scope.internal": "Internal", - "remoteAccess.address.open": "Open", - "remoteAccess.address.showQr": "Show QR", - "remoteAccess.address.hideQr": "Hide QR", - "remoteAccess.address.qrAlt": "QR for {url}", -} as const diff --git a/packages/ui/src/lib/i18n/messages/en/remoteControl.ts b/packages/ui/src/lib/i18n/messages/en/remoteControl.ts new file mode 100644 index 000000000..bc710f58c --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/en/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Remote Control", + "remoteControl.description": "Continue your local CodeNomad sessions from another device without opening network ports.", + "remoteControl.refresh": "Refresh", + "remoteControl.state.stopped": "Remote Control is off", + "remoteControl.state.connecting": "Connecting to the relay…", + "remoteControl.state.connected": "Ready for remote connections", + "remoteControl.state.reconnecting": "Reconnecting…", + "remoteControl.state.error": "Relay connection failed", + "remoteControl.status.localOnly": "CodeNomad is available on this device only.", + "remoteControl.status.hostOnly": "Manage Remote Control from the CodeNomad host.", + "remoteControl.start": "Start Remote Control", + "remoteControl.newLink": "Create new pairing link", + "remoteControl.stop": "Stop Remote Control", + "remoteControl.pairing.title": "Connect another device", + "remoteControl.pairing.description": "Scan this QR code or open the one-time link on your other device.", + "remoteControl.pairing.qrAlt": "Remote Control pairing QR code", + "remoteControl.pairing.copy": "Copy link", + "remoteControl.pairing.copied": "Copied", + "remoteControl.pairing.expires": "This link expires at {time}.", + "remoteControl.devices.title": "Paired devices", + "remoteControl.devices.description": "These devices can access this CodeNomad host while Remote Control is running.", + "remoteControl.devices.empty": "No devices are paired yet.", + "remoteControl.devices.lastSeen": "Last seen {time}", + "remoteControl.devices.revoke": "Revoke", +} as const diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index e5c2063e7..111e0a6d8 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Hold long assistant replies", "settings.behavior.holdLongAssistantReplies.subtitle": "Stop following automatically when a streaming reply grows beyond the viewport.", "settings.nav.notifications": "Notifications", - "settings.nav.remote": "Remote Access", + "settings.nav.remote": "Remote Control", "settings.nav.speech": "Speech", "settings.nav.providers": "Providers", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Notifications enabled", "settings.notifications.status.disabled": "Notifications disabled", "settings.notifications.status.unsupported": "Notifications unsupported", - "settings.section.remote.title": "Remote Access", - "settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.", + "settings.section.remote.title": "Remote Control", + "settings.section.remote.subtitle": "Securely continue this device's sessions from another device through the outbound relay.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", diff --git a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts index 65d228b53..d77ec086c 100644 --- a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts @@ -41,7 +41,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "La URL del repositorio y la carpeta de destino son obligatorias.", "folderSelection.actions.title": "Abrir carpeta o conectar servidor", "folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad", - "folderSelection.actions.connectButton": "Conectar servidor CodeNomad", "folderSelection.advancedSettings": "Configuración avanzada", "folderSelection.opencode": "OpenCode", @@ -63,36 +62,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.", "folderSelection.tabs.local": "Carpetas locales", - "folderSelection.tabs.servers": "Servidores", - "folderSelection.servers.title": "Servidores guardados", - "folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva", - "folderSelection.servers.count": "{count} servidores", - "folderSelection.servers.empty.title": "No hay servidores guardados", - "folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo", - "folderSelection.servers.connectTitle": "Conectar a un servidor", - "folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva", - "folderSelection.servers.connectButton": "Conectar a un servidor", - "folderSelection.servers.remove": "Eliminar servidor guardado", - "folderSelection.servers.skipTls": "TLS autofirmado", - "folderSelection.servers.errorTitle": "Falló la conexión remota", - "folderSelection.servers.dialog.title": "Conectar a un servidor", - "folderSelection.servers.dialog.description": "Añade un servidor remoto de CodeNomad y ábrelo ahora si quieres.", - "folderSelection.servers.dialog.name": "Nombre del servidor", - "folderSelection.servers.dialog.namePlaceholder": "Servidor de producción", - "folderSelection.servers.dialog.url": "URL del servidor", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", - "folderSelection.servers.dialog.skipTls": "Omitir la verificación TLS para certificados autofirmados.", - "folderSelection.servers.dialog.cancel": "Cancelar", - "folderSelection.servers.dialog.save": "Guardar", - "folderSelection.servers.dialog.connect": "Conectar", - "folderSelection.servers.dialog.connecting": "Conectando...", - "folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.", - "folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.", - "folderSelection.servers.certificateInstall.title": "Instalar certificado local", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el tráfico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado después de esto.", - "folderSelection.servers.certificateInstall.confirmLabel": "Continuar", - "folderSelection.servers.certificateInstall.cancelLabel": "Cancelar", - "folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.", "folderSelection.sidecars.button": "Abrir SideCar", "projectRenameDialog.title": "Renombrar workspace", diff --git a/packages/ui/src/lib/i18n/messages/es/index.ts b/packages/ui/src/lib/i18n/messages/es/index.ts index 9bf3d8c1d..7d97c2ee2 100644 --- a/packages/ui/src/lib/i18n/messages/es/index.ts +++ b/packages/ui/src/lib/i18n/messages/es/index.ts @@ -9,7 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -31,6 +31,6 @@ export const esMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, - remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts deleted file mode 100644 index f372d60c4..000000000 --- a/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "Transferencia remota", - "remoteAccess.title": "Conectar a CodeNomad de forma remota", - "remoteAccess.subtitle": "Usa las direcciones de abajo para abrir CodeNomad desde otro dispositivo.", - "remoteAccess.close": "Cerrar acceso remoto", - "remoteAccess.refresh": "Actualizar", - - "remoteAccess.sections.listeningMode.label": "Modo de escucha", - "remoteAccess.sections.listeningMode.help": "Permite o limita las transferencias remotas vinculando a todas las interfaces o solo a localhost.", - "remoteAccess.toggle.on": "Activado", - "remoteAccess.toggle.off": "Desactivado", - "remoteAccess.toggle.title": "Permitir conexiones desde otras IP", - "remoteAccess.toggle.caption.all": "Vinculado a 0.0.0.0", - "remoteAccess.toggle.caption.local": "Vinculado a 127.0.0.1", - "remoteAccess.toggle.note": "Cambiar esto requiere reiniciar y detiene temporalmente todas las instancias activas. Comparte las direcciones de abajo una vez que el servidor se reinicie.", - "remoteAccess.listeningMode.restartConfirm.message": "¿Reiniciar para aplicar el modo de escucha? Esto detendrá todas las instancias en ejecución.", - "remoteAccess.listeningMode.restartConfirm.title.all": "Abrir a otros dispositivos", - "remoteAccess.listeningMode.restartConfirm.title.local": "Limitar a este dispositivo", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Reiniciar ahora", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Cancelar", - "remoteAccess.restart.errorManual": "No se pudo reiniciar automáticamente. Reinicia la app para aplicar el cambio.", - - "remoteAccess.sections.serverPassword.label": "Contraseña del servidor", - "remoteAccess.sections.serverPassword.help": "Las transferencias remotas requieren una contraseña. Define una fácil de recordar para habilitar inicios de sesión desde otros dispositivos.", - "remoteAccess.authStatus.unavailable": "Estado de autenticación no disponible.", - "remoteAccess.username": "Usuario: {username}", - "remoteAccess.password.status.set": "Hay una contraseña configurada para el acceso remoto.", - "remoteAccess.password.status.unset": "Aún no hay una contraseña fácil de recordar. Configura una para permitir inicios de sesión por transferencia remota.", - "remoteAccess.password.actions.cancel": "Cancelar", - "remoteAccess.password.actions.change": "Cambiar contraseña", - "remoteAccess.password.actions.set": "Configurar contraseña", - "remoteAccess.password.form.newPassword": "Nueva contraseña", - "remoteAccess.password.form.confirmPassword": "Confirmar contraseña", - "remoteAccess.password.form.placeholder": "Al menos 8 caracteres", - "remoteAccess.password.error.tooShort": "La contraseña debe tener al menos 8 caracteres.", - "remoteAccess.password.error.mismatch": "Las contraseñas no coinciden.", - "remoteAccess.password.save.saving": "Guardando…", - "remoteAccess.password.save.label": "Guardar contraseña", - - "remoteAccess.sections.addresses.label": "Direcciones accesibles", - "remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.", - "remoteAccess.addresses.loading": "Cargando direcciones…", - "remoteAccess.addresses.none": "Aún no hay direcciones disponibles.", - "remoteAccess.addresses.actions.showOther": "Mostrar {count} direcciones más", - "remoteAccess.addresses.actions.hideOther": "Ocultar otras direcciones", - "remoteAccess.address.scope.network": "Red", - "remoteAccess.address.scope.loopback": "Loopback", - "remoteAccess.address.scope.internal": "Interna", - "remoteAccess.address.open": "Abrir", - "remoteAccess.address.showQr": "Mostrar QR", - "remoteAccess.address.hideQr": "Ocultar QR", - "remoteAccess.address.qrAlt": "QR para {url}", -} as const diff --git a/packages/ui/src/lib/i18n/messages/es/remoteControl.ts b/packages/ui/src/lib/i18n/messages/es/remoteControl.ts new file mode 100644 index 000000000..2e4d74af6 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/es/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Control remoto", + "remoteControl.description": "Continúa tus sesiones locales de CodeNomad desde otro dispositivo sin abrir puertos de red.", + "remoteControl.refresh": "Actualizar", + "remoteControl.state.stopped": "El control remoto está desactivado", + "remoteControl.state.connecting": "Conectando al relé…", + "remoteControl.state.connected": "Listo para conexiones remotas", + "remoteControl.state.reconnecting": "Reconectando…", + "remoteControl.state.error": "Falló la conexión al relé", + "remoteControl.status.localOnly": "CodeNomad solo está disponible en este dispositivo.", + "remoteControl.status.hostOnly": "Gestiona Remote Control desde el equipo anfitrión de CodeNomad.", + "remoteControl.start": "Iniciar control remoto", + "remoteControl.newLink": "Crear nuevo enlace de emparejamiento", + "remoteControl.stop": "Detener control remoto", + "remoteControl.pairing.title": "Conectar otro dispositivo", + "remoteControl.pairing.description": "Escanea este código QR o abre el enlace de un solo uso en el otro dispositivo.", + "remoteControl.pairing.qrAlt": "Código QR de emparejamiento del control remoto", + "remoteControl.pairing.copy": "Copiar enlace", + "remoteControl.pairing.copied": "Copiado", + "remoteControl.pairing.expires": "Este enlace caduca a las {time}.", + "remoteControl.devices.title": "Dispositivos emparejados", + "remoteControl.devices.description": "Estos dispositivos pueden acceder a este host mientras el control remoto está activo.", + "remoteControl.devices.empty": "Todavía no hay dispositivos emparejados.", + "remoteControl.devices.lastSeen": "Última actividad: {time}", + "remoteControl.devices.revoke": "Revocar", +} as const diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 39a8fb460..98fd65cb7 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Retener respuestas largas del asistente", "settings.behavior.holdLongAssistantReplies.subtitle": "Deja de seguir automáticamente cuando una respuesta en curso supera la ventana.", "settings.nav.notifications": "Notificaciones", - "settings.nav.remote": "Acceso remoto", + "settings.nav.remote": "Control remoto", "settings.nav.speech": "Voz", "settings.nav.providers": "Proveedores", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Notificaciones activadas", "settings.notifications.status.disabled": "Notificaciones desactivadas", "settings.notifications.status.unsupported": "Notificaciones no compatibles", - "settings.section.remote.title": "Acceso remoto", - "settings.section.remote.subtitle": "Revisa cómo se expone este servidor en tu red y protege las credenciales de acceso.", + "settings.section.remote.title": "Control remoto", + "settings.section.remote.subtitle": "Continúa de forma segura las sesiones de este dispositivo desde otro mediante el relé saliente.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Elige el binario de OpenCode y el entorno usados para nuevas instancias.", "settings.opencode.runtime.title": "Runtime", diff --git a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts index 3190c23c0..0ef97b0ed 100644 --- a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts @@ -41,7 +41,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "L'URL du depot et le dossier de destination sont requis.", "folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur", "folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad", - "folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad", "folderSelection.advancedSettings": "Paramètres avancés", "folderSelection.opencode": "OpenCode", @@ -63,36 +62,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.", "folderSelection.tabs.local": "Dossiers locaux", - "folderSelection.tabs.servers": "Serveurs", - "folderSelection.servers.title": "Serveurs enregistrés", - "folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre", - "folderSelection.servers.count": "{count} serveurs", - "folderSelection.servers.empty.title": "Aucun serveur enregistré", - "folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil", - "folderSelection.servers.connectTitle": "Se connecter à un serveur", - "folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre", - "folderSelection.servers.connectButton": "Se connecter à un serveur", - "folderSelection.servers.remove": "Supprimer le serveur enregistré", - "folderSelection.servers.skipTls": "TLS auto-signé", - "folderSelection.servers.errorTitle": "Échec de la connexion distante", - "folderSelection.servers.dialog.title": "Se connecter à un serveur", - "folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.", - "folderSelection.servers.dialog.name": "Nom du serveur", - "folderSelection.servers.dialog.namePlaceholder": "Serveur de production", - "folderSelection.servers.dialog.url": "URL du serveur", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", - "folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.", - "folderSelection.servers.dialog.cancel": "Annuler", - "folderSelection.servers.dialog.save": "Enregistrer", - "folderSelection.servers.dialog.connect": "Se connecter", - "folderSelection.servers.dialog.connecting": "Connexion...", - "folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.", - "folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.", - "folderSelection.servers.certificateInstall.title": "Installer le certificat local", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.", - "folderSelection.servers.certificateInstall.confirmLabel": "Continuer", - "folderSelection.servers.certificateInstall.cancelLabel": "Annuler", - "folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.", "folderSelection.sidecars.button": "Ouvrir SideCar", "projectRenameDialog.title": "Renommer l'espace de travail", diff --git a/packages/ui/src/lib/i18n/messages/fr/index.ts b/packages/ui/src/lib/i18n/messages/fr/index.ts index ac9d1c6e2..efa409c7c 100644 --- a/packages/ui/src/lib/i18n/messages/fr/index.ts +++ b/packages/ui/src/lib/i18n/messages/fr/index.ts @@ -9,7 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -31,6 +31,6 @@ export const frMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, - remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts deleted file mode 100644 index 3b6c17add..000000000 --- a/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "Passation à distance", - "remoteAccess.title": "Se connecter à CodeNomad à distance", - "remoteAccess.subtitle": "Utilisez les adresses ci-dessous pour ouvrir CodeNomad depuis un autre appareil.", - "remoteAccess.close": "Fermer l'accès à distance", - "remoteAccess.refresh": "Rafraîchir", - - "remoteAccess.sections.listeningMode.label": "Mode d'écoute", - "remoteAccess.sections.listeningMode.help": "Autorisez ou limitez les passations à distance en écoutant sur toutes les interfaces ou uniquement sur localhost.", - "remoteAccess.toggle.on": "Activé", - "remoteAccess.toggle.off": "Désactivé", - "remoteAccess.toggle.title": "Autoriser les connexions depuis d'autres IP", - "remoteAccess.toggle.caption.all": "Écoute sur 0.0.0.0", - "remoteAccess.toggle.caption.local": "Écoute sur 127.0.0.1", - "remoteAccess.toggle.note": "Modifier ceci nécessite un redémarrage et stoppe temporairement toutes les instances actives. Partagez les adresses ci-dessous une fois le serveur redémarré.", - "remoteAccess.listeningMode.restartConfirm.message": "Redémarrer pour appliquer le mode d'écoute ? Cela arrêtera toutes les instances en cours.", - "remoteAccess.listeningMode.restartConfirm.title.all": "Ouvrir aux autres appareils", - "remoteAccess.listeningMode.restartConfirm.title.local": "Limiter à cet appareil", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Redémarrer maintenant", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Annuler", - "remoteAccess.restart.errorManual": "Impossible de redémarrer automatiquement. Veuillez redémarrer l'application pour appliquer le changement.", - - "remoteAccess.sections.serverPassword.label": "Mot de passe du serveur", - "remoteAccess.sections.serverPassword.help": "Les passations à distance nécessitent un mot de passe. Définissez-en un facile à retenir pour autoriser la connexion depuis d'autres appareils.", - "remoteAccess.authStatus.unavailable": "Statut d'authentification indisponible.", - "remoteAccess.username": "Nom d'utilisateur : {username}", - "remoteAccess.password.status.set": "Un mot de passe est défini pour l'accès à distance.", - "remoteAccess.password.status.unset": "Aucun mot de passe mémorable n'est encore défini. Définissez-en un pour autoriser les connexions à distance.", - "remoteAccess.password.actions.cancel": "Annuler", - "remoteAccess.password.actions.change": "Changer le mot de passe", - "remoteAccess.password.actions.set": "Définir le mot de passe", - "remoteAccess.password.form.newPassword": "Nouveau mot de passe", - "remoteAccess.password.form.confirmPassword": "Confirmer le mot de passe", - "remoteAccess.password.form.placeholder": "Au moins 8 caractères", - "remoteAccess.password.error.tooShort": "Le mot de passe doit contenir au moins 8 caractères.", - "remoteAccess.password.error.mismatch": "Les mots de passe ne correspondent pas.", - "remoteAccess.password.save.saving": "Enregistrement…", - "remoteAccess.password.save.label": "Enregistrer le mot de passe", - - "remoteAccess.sections.addresses.label": "Adresses accessibles", - "remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.", - "remoteAccess.addresses.loading": "Chargement des adresses…", - "remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.", - "remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses", - "remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses", - "remoteAccess.address.scope.network": "Réseau", - "remoteAccess.address.scope.loopback": "Boucle locale", - "remoteAccess.address.scope.internal": "Interne", - "remoteAccess.address.open": "Ouvrir", - "remoteAccess.address.showQr": "Afficher le QR", - "remoteAccess.address.hideQr": "Masquer le QR", - "remoteAccess.address.qrAlt": "QR pour {url}", -} as const diff --git a/packages/ui/src/lib/i18n/messages/fr/remoteControl.ts b/packages/ui/src/lib/i18n/messages/fr/remoteControl.ts new file mode 100644 index 000000000..087fba782 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/fr/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Contrôle à distance", + "remoteControl.description": "Continuez vos sessions CodeNomad locales depuis un autre appareil sans ouvrir de port réseau.", + "remoteControl.refresh": "Actualiser", + "remoteControl.state.stopped": "Le contrôle à distance est désactivé", + "remoteControl.state.connecting": "Connexion au relais…", + "remoteControl.state.connected": "Prêt pour les connexions distantes", + "remoteControl.state.reconnecting": "Reconnexion…", + "remoteControl.state.error": "Échec de la connexion au relais", + "remoteControl.status.localOnly": "CodeNomad est disponible uniquement sur cet appareil.", + "remoteControl.status.hostOnly": "Gère Remote Control depuis l’hôte CodeNomad.", + "remoteControl.start": "Démarrer le contrôle à distance", + "remoteControl.newLink": "Créer un nouveau lien d’appairage", + "remoteControl.stop": "Arrêter le contrôle à distance", + "remoteControl.pairing.title": "Connecter un autre appareil", + "remoteControl.pairing.description": "Scannez ce QR code ou ouvrez le lien à usage unique sur l’autre appareil.", + "remoteControl.pairing.qrAlt": "QR code d’appairage du contrôle à distance", + "remoteControl.pairing.copy": "Copier le lien", + "remoteControl.pairing.copied": "Copié", + "remoteControl.pairing.expires": "Ce lien expire à {time}.", + "remoteControl.devices.title": "Appareils appairés", + "remoteControl.devices.description": "Ces appareils peuvent accéder à cet hôte CodeNomad pendant le contrôle à distance.", + "remoteControl.devices.empty": "Aucun appareil n’est encore appairé.", + "remoteControl.devices.lastSeen": "Dernière activité : {time}", + "remoteControl.devices.revoke": "Révoquer", +} as const diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 5b48289fe..86e57d229 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Suspendre le suivi des longues réponses", "settings.behavior.holdLongAssistantReplies.subtitle": "Arrêter le suivi automatique lorsqu'une réponse en cours dépasse la fenêtre.", "settings.nav.notifications": "Notifications", - "settings.nav.remote": "Accès distant", + "settings.nav.remote": "Contrôle à distance", "settings.nav.speech": "Voix", "settings.nav.providers": "Fournisseurs", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Notifications activées", "settings.notifications.status.disabled": "Notifications désactivées", "settings.notifications.status.unsupported": "Notifications non prises en charge", - "settings.section.remote.title": "Accès distant", - "settings.section.remote.subtitle": "Vérifiez comment ce serveur est exposé sur votre réseau et sécurisez les identifiants d'accès.", + "settings.section.remote.title": "Contrôle à distance", + "settings.section.remote.subtitle": "Continuez les sessions de cet appareil depuis un autre appareil via le relais sortant sécurisé.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Choisissez le binaire OpenCode et l'environnement utilisés pour les nouvelles instances.", "settings.opencode.runtime.title": "Environnement d'exécution", diff --git a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts index 26f8dc4b8..b15e4df48 100644 --- a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts @@ -41,7 +41,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "כתובת המאגר ותיקיית היעד הן שדות חובה.", "folderSelection.actions.title": "פתח תיקייה או התחבר לשרת", "folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad", - "folderSelection.actions.connectButton": "התחבר לשרת CodeNomad", "folderSelection.advancedSettings": "הגדרות מתקדמות", "folderSelection.opencode": "OpenCode", @@ -63,36 +62,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.", "folderSelection.tabs.local": "תיקיות מקומיות", - "folderSelection.tabs.servers": "שרתים", - "folderSelection.servers.title": "שרתים שמורים", - "folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש", - "folderSelection.servers.count": "{count} שרתים", - "folderSelection.servers.empty.title": "אין שרתים שמורים", - "folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה", - "folderSelection.servers.connectTitle": "התחבר לשרת", - "folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש", - "folderSelection.servers.connectButton": "התחבר לשרת", - "folderSelection.servers.remove": "הסר שרת שמור", - "folderSelection.servers.skipTls": "TLS בחתימה עצמית", - "folderSelection.servers.errorTitle": "החיבור המרוחק נכשל", - "folderSelection.servers.dialog.title": "התחבר לשרת", - "folderSelection.servers.dialog.description": "הוסף שרת CodeNomad מרוחק ופתח אותו מיד אם תרצה.", - "folderSelection.servers.dialog.name": "שם השרת", - "folderSelection.servers.dialog.namePlaceholder": "שרת ייצור", - "folderSelection.servers.dialog.url": "כתובת השרת", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", - "folderSelection.servers.dialog.skipTls": "דלג על אימות TLS עבור תעודות בחתימה עצמית.", - "folderSelection.servers.dialog.cancel": "ביטול", - "folderSelection.servers.dialog.save": "שמור", - "folderSelection.servers.dialog.connect": "התחבר", - "folderSelection.servers.dialog.connecting": "מתחבר...", - "folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.", - "folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.", - "folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.", - "folderSelection.servers.certificateInstall.confirmLabel": "המשך", - "folderSelection.servers.certificateInstall.cancelLabel": "ביטול", - "folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.", "folderSelection.sidecars.button": "פתח SideCar", "projectRenameDialog.title": "שנה שם סביבת עבודה", diff --git a/packages/ui/src/lib/i18n/messages/he/index.ts b/packages/ui/src/lib/i18n/messages/he/index.ts index cee41090a..c946829a8 100644 --- a/packages/ui/src/lib/i18n/messages/he/index.ts +++ b/packages/ui/src/lib/i18n/messages/he/index.ts @@ -9,7 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -31,6 +31,6 @@ export const heMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, - remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts deleted file mode 100644 index dc026c46d..000000000 --- a/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "גישה מרוחקת", - "remoteAccess.title": "התחבר ל-CodeNomad מרחוק", - "remoteAccess.subtitle": "השתמש בכתובות למטה כדי לפתוח את CodeNomad ממכשיר אחר.", - "remoteAccess.close": "סגור גישה מרוחקת", - "remoteAccess.refresh": "רענן", - - "remoteAccess.sections.listeningMode.label": "מצב האזנה", - "remoteAccess.sections.listeningMode.help": "אפשר או הגבל גישה מרוחקת על ידי קישור לכל הממשקים או רק ל-localhost.", - "remoteAccess.toggle.on": "פועל", - "remoteAccess.toggle.off": "כבוי", - "remoteAccess.toggle.title": "אפשר חיבורים מכתובות IP אחרות", - "remoteAccess.toggle.caption.all": "מקושר ל-0.0.0.0", - "remoteAccess.toggle.caption.local": "מקושר ל-127.0.0.1", - "remoteAccess.toggle.note": "שינוי זה דורש הפעלה מחדש ועוצר זמנית את כל המופעים הפעילים. שתף את הכתובות למטה לאחר שהשרת יופעל מחדש.", - "remoteAccess.listeningMode.restartConfirm.message": "להפעיל מחדש כדי להחיל מצב האזנה? פעולה זו תעצור את כל המופעים הפעילים.", - "remoteAccess.listeningMode.restartConfirm.title.all": "פתוח למכשירים אחרים", - "remoteAccess.listeningMode.restartConfirm.title.local": "מוגבל למכשיר זה", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "הפעל מחדש עכשיו", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "ביטול", - "remoteAccess.restart.errorManual": "לא ניתן להפעיל מחדש אוטומטית. אנא הפעל מחדש את האפליקציה כדי להחיל את השינוי.", - - "remoteAccess.sections.serverPassword.label": "סיסמת שרת", - "remoteAccess.sections.serverPassword.help": "גישה מרוחקת דורשת סיסמה. הגדר סיסמה קלה לזכירה כדי לאפשר כניסות ממכשירים אחרים.", - "remoteAccess.authStatus.unavailable": "סטטוס האימות אינו זמין.", - "remoteAccess.username": "שם משתמש: {username}", - "remoteAccess.password.status.set": "סיסמה מוגדרת לגישה מרוחקת.", - "remoteAccess.password.status.unset": "לא הוגדרה סיסמה קלה לזכירה. הגדר סיסמה כדי לאפשר כניסות גישה מרוחקת.", - "remoteAccess.password.actions.cancel": "ביטול", - "remoteAccess.password.actions.change": "שנה סיסמה", - "remoteAccess.password.actions.set": "הגדר סיסמה", - "remoteAccess.password.form.newPassword": "סיסמה חדשה", - "remoteAccess.password.form.confirmPassword": "אשר סיסמה", - "remoteAccess.password.form.placeholder": "לפחות 8 תווים", - "remoteAccess.password.error.tooShort": "הסיסמה חייבת להכיל לפחות 8 תווים.", - "remoteAccess.password.error.mismatch": "הסיסמאות אינן תואמות.", - "remoteAccess.password.save.saving": "שומר…", - "remoteAccess.password.save.label": "שמור סיסמה", - - "remoteAccess.sections.addresses.label": "כתובות נגישות", - "remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.", - "remoteAccess.addresses.loading": "טוען כתובות…", - "remoteAccess.addresses.none": "אין כתובות זמינות עדיין.", - "remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות", - "remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות", - "remoteAccess.address.scope.network": "רשת", - "remoteAccess.address.scope.loopback": "לולאה מקומית", - "remoteAccess.address.scope.internal": "פנימי", - "remoteAccess.address.open": "פתח", - "remoteAccess.address.showQr": "הצג QR", - "remoteAccess.address.hideQr": "הסתר QR", - "remoteAccess.address.qrAlt": "QR עבור {url}", -} as const diff --git a/packages/ui/src/lib/i18n/messages/he/remoteControl.ts b/packages/ui/src/lib/i18n/messages/he/remoteControl.ts new file mode 100644 index 000000000..48fe8f583 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/he/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "שליטה מרחוק", + "remoteControl.description": "המשך הפעלות CodeNomad מקומיות ממכשיר אחר בלי לפתוח יציאות רשת.", + "remoteControl.refresh": "רענון", + "remoteControl.state.stopped": "השליטה מרחוק כבויה", + "remoteControl.state.connecting": "מתחבר לממסר…", + "remoteControl.state.connected": "מוכן לחיבורים מרחוק", + "remoteControl.state.reconnecting": "מתחבר מחדש…", + "remoteControl.state.error": "החיבור לממסר נכשל", + "remoteControl.status.localOnly": "CodeNomad זמין רק במכשיר זה.", + "remoteControl.status.hostOnly": "יש לנהל את Remote Control מהמחשב המארח של CodeNomad.", + "remoteControl.start": "הפעל שליטה מרחוק", + "remoteControl.newLink": "צור קישור צימוד חדש", + "remoteControl.stop": "עצור שליטה מרחוק", + "remoteControl.pairing.title": "חיבור מכשיר נוסף", + "remoteControl.pairing.description": "סרוק את קוד ה-QR או פתח את הקישור החד-פעמי במכשיר האחר.", + "remoteControl.pairing.qrAlt": "קוד QR לצימוד שליטה מרחוק", + "remoteControl.pairing.copy": "העתק קישור", + "remoteControl.pairing.copied": "הועתק", + "remoteControl.pairing.expires": "הקישור יפוג בשעה {time}.", + "remoteControl.devices.title": "מכשירים מצומדים", + "remoteControl.devices.description": "מכשירים אלה יכולים לגשת למארח כאשר השליטה מרחוק פעילה.", + "remoteControl.devices.empty": "עדיין אין מכשירים מצומדים.", + "remoteControl.devices.lastSeen": "נראה לאחרונה: {time}", + "remoteControl.devices.revoke": "בטל גישה", +} as const diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 8eafb913d..de635952e 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "השהיית מעקב אחר תשובות ארוכות", "settings.behavior.holdLongAssistantReplies.subtitle": "הפסק מעקב אוטומטי כאשר תשובה זורמת חורגת מחלון התצוגה.", "settings.nav.notifications": "התראות", - "settings.nav.remote": "גישה מרוחקת", + "settings.nav.remote": "שליטה מרחוק", "settings.nav.speech": "קול", "settings.nav.providers": "ספקים", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "התראות מופעלות", "settings.notifications.status.disabled": "התראות מושבתות", "settings.notifications.status.unsupported": "התראות לא נתמכות", - "settings.section.remote.title": "גישה מרוחקת", - "settings.section.remote.subtitle": "בדוק כיצד שרת זה חשוף ברשת שלך ואבטח אישורי גישה.", + "settings.section.remote.title": "שליטה מרחוק", + "settings.section.remote.subtitle": "המשך באופן מאובטח את ההפעלות של מכשיר זה ממכשיר אחר דרך הממסר היוצא.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.", "settings.opencode.runtime.title": "סביבת ריצה", diff --git a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts index 230528651..1a61d656c 100644 --- a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts @@ -41,7 +41,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "リポジトリ URL と保存先フォルダは必須です。", "folderSelection.actions.title": "フォルダを開くかサーバーに接続", "folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します", - "folderSelection.actions.connectButton": "CodeNomad サーバーに接続", "folderSelection.advancedSettings": "詳細設定", "folderSelection.opencode": "OpenCode", @@ -63,36 +62,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。", "folderSelection.tabs.local": "ローカルフォルダ", - "folderSelection.tabs.servers": "サーバー", - "folderSelection.servers.title": "保存済みサーバー", - "folderSelection.servers.subtitle": "保存したリモート CodeNomad サーバーを新しいウィンドウで開きます", - "folderSelection.servers.count": "{count} サーバー", - "folderSelection.servers.empty.title": "保存済みサーバーはありません", - "folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーを追加してください", - "folderSelection.servers.connectTitle": "サーバーに接続", - "folderSelection.servers.connectSubtitle": "リモート CodeNomad サーバーを保存して新しいウィンドウで開きます", - "folderSelection.servers.connectButton": "サーバーに接続", - "folderSelection.servers.remove": "保存したサーバーを削除", - "folderSelection.servers.skipTls": "自己署名 TLS", - "folderSelection.servers.errorTitle": "リモート接続に失敗しました", - "folderSelection.servers.dialog.title": "サーバーに接続", - "folderSelection.servers.dialog.description": "リモート CodeNomad サーバーを追加し、必要に応じてすぐに開きます。", - "folderSelection.servers.dialog.name": "サーバー名", - "folderSelection.servers.dialog.namePlaceholder": "本番サーバー", - "folderSelection.servers.dialog.url": "サーバー URL", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", - "folderSelection.servers.dialog.skipTls": "自己署名証明書の TLS 検証をスキップします。", - "folderSelection.servers.dialog.cancel": "キャンセル", - "folderSelection.servers.dialog.save": "保存", - "folderSelection.servers.dialog.connect": "接続", - "folderSelection.servers.dialog.connecting": "接続中...", - "folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。", - "folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。", - "folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。", - "folderSelection.servers.certificateInstall.confirmLabel": "続行", - "folderSelection.servers.certificateInstall.cancelLabel": "キャンセル", - "folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。", "folderSelection.sidecars.button": "SideCar を開く", "projectRenameDialog.title": "ワークスペース名を変更", diff --git a/packages/ui/src/lib/i18n/messages/ja/index.ts b/packages/ui/src/lib/i18n/messages/ja/index.ts index 407f06976..5d676be56 100644 --- a/packages/ui/src/lib/i18n/messages/ja/index.ts +++ b/packages/ui/src/lib/i18n/messages/ja/index.ts @@ -9,7 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -31,6 +31,6 @@ export const jaMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, - remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts deleted file mode 100644 index 996b481e1..000000000 --- a/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "リモート引き継ぎ", - "remoteAccess.title": "CodeNomad にリモート接続", - "remoteAccess.subtitle": "別のデバイスから CodeNomad を開くには、以下のアドレスを使用してください。", - "remoteAccess.close": "リモートアクセスを閉じる", - "remoteAccess.refresh": "更新", - - "remoteAccess.sections.listeningMode.label": "リッスンモード", - "remoteAccess.sections.listeningMode.help": "全インターフェースにバインドするか localhost のみにするかで、リモート引き継ぎを許可/制限します。", - "remoteAccess.toggle.on": "オン", - "remoteAccess.toggle.off": "オフ", - "remoteAccess.toggle.title": "他の IP からの接続を許可", - "remoteAccess.toggle.caption.all": "0.0.0.0 にバインド", - "remoteAccess.toggle.caption.local": "127.0.0.1 にバインド", - "remoteAccess.toggle.note": "変更には再起動が必要で、すべての稼働中インスタンスが一時的に停止します。サーバー再起動後に以下のアドレスを共有してください。", - "remoteAccess.listeningMode.restartConfirm.message": "リッスンモードを適用するため再起動しますか?実行中のインスタンスはすべて停止します。", - "remoteAccess.listeningMode.restartConfirm.title.all": "他のデバイスに公開", - "remoteAccess.listeningMode.restartConfirm.title.local": "このデバイスに限定", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "今すぐ再起動", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "キャンセル", - "remoteAccess.restart.errorManual": "自動で再起動できませんでした。変更を適用するにはアプリを再起動してください。", - - "remoteAccess.sections.serverPassword.label": "サーバーパスワード", - "remoteAccess.sections.serverPassword.help": "リモート引き継ぎにはパスワードが必要です。覚えやすいものを設定して他のデバイスからのログインを有効にします。", - "remoteAccess.authStatus.unavailable": "認証状態を取得できません。", - "remoteAccess.username": "ユーザー名: {username}", - "remoteAccess.password.status.set": "リモートアクセス用のパスワードが設定されています。", - "remoteAccess.password.status.unset": "まだ覚えやすいパスワードが設定されていません。設定してリモート引き継ぎログインを有効にしてください。", - "remoteAccess.password.actions.cancel": "キャンセル", - "remoteAccess.password.actions.change": "パスワードを変更", - "remoteAccess.password.actions.set": "パスワードを設定", - "remoteAccess.password.form.newPassword": "新しいパスワード", - "remoteAccess.password.form.confirmPassword": "パスワードの確認", - "remoteAccess.password.form.placeholder": "8 文字以上", - "remoteAccess.password.error.tooShort": "パスワードは 8 文字以上である必要があります。", - "remoteAccess.password.error.mismatch": "パスワードが一致しません。", - "remoteAccess.password.save.saving": "保存中…", - "remoteAccess.password.save.label": "パスワードを保存", - - "remoteAccess.sections.addresses.label": "到達可能なアドレス", - "remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。", - "remoteAccess.addresses.loading": "アドレスを読み込み中…", - "remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。", - "remoteAccess.addresses.actions.showOther": "他の {count} 件のアドレスを表示", - "remoteAccess.addresses.actions.hideOther": "他のアドレスを隠す", - "remoteAccess.address.scope.network": "ネットワーク", - "remoteAccess.address.scope.loopback": "ループバック", - "remoteAccess.address.scope.internal": "内部", - "remoteAccess.address.open": "開く", - "remoteAccess.address.showQr": "QR を表示", - "remoteAccess.address.hideQr": "QR を非表示", - "remoteAccess.address.qrAlt": "{url} の QR", -} as const diff --git a/packages/ui/src/lib/i18n/messages/ja/remoteControl.ts b/packages/ui/src/lib/i18n/messages/ja/remoteControl.ts new file mode 100644 index 000000000..0e3c113c2 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ja/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "リモートコントロール", + "remoteControl.description": "ネットワークポートを開かずに、別の端末からローカルの CodeNomad セッションを続けます。", + "remoteControl.refresh": "更新", + "remoteControl.state.stopped": "リモートコントロールはオフです", + "remoteControl.state.connecting": "リレーに接続中…", + "remoteControl.state.connected": "リモート接続の準備ができました", + "remoteControl.state.reconnecting": "再接続中…", + "remoteControl.state.error": "リレーへの接続に失敗しました", + "remoteControl.status.localOnly": "CodeNomad はこの端末でのみ利用できます。", + "remoteControl.status.hostOnly": "Remote Control は CodeNomad ホストで管理してください。", + "remoteControl.start": "リモートコントロールを開始", + "remoteControl.newLink": "新しいペアリングリンクを作成", + "remoteControl.stop": "リモートコントロールを停止", + "remoteControl.pairing.title": "別の端末を接続", + "remoteControl.pairing.description": "QR コードをスキャンするか、別の端末で一度限りのリンクを開いてください。", + "remoteControl.pairing.qrAlt": "リモートコントロールのペアリング QR コード", + "remoteControl.pairing.copy": "リンクをコピー", + "remoteControl.pairing.copied": "コピーしました", + "remoteControl.pairing.expires": "このリンクは {time} に期限切れになります。", + "remoteControl.devices.title": "ペアリング済み端末", + "remoteControl.devices.description": "リモートコントロールの実行中、これらの端末からこのホストにアクセスできます。", + "remoteControl.devices.empty": "ペアリング済み端末はありません。", + "remoteControl.devices.lastSeen": "最終アクセス: {time}", + "remoteControl.devices.revoke": "取り消す", +} as const diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index ffee6a953..f6ec59126 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "長いアシスタント応答を保持", "settings.behavior.holdLongAssistantReplies.subtitle": "ストリーミング応答が画面を超えたときに自動追従を停止します。", "settings.nav.notifications": "通知", - "settings.nav.remote": "リモートアクセス", + "settings.nav.remote": "リモートコントロール", "settings.nav.speech": "音声", "settings.nav.providers": "プロバイダー", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "通知は有効です", "settings.notifications.status.disabled": "通知は無効です", "settings.notifications.status.unsupported": "通知は未対応です", - "settings.section.remote.title": "リモートアクセス", - "settings.section.remote.subtitle": "このサーバーがネットワーク上でどのように公開されているかと、アクセス認証情報を確認します。", + "settings.section.remote.title": "リモートコントロール", + "settings.section.remote.subtitle": "安全な送信専用リレーを介して、別の端末からこの端末のセッションを続けます。", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "新しいインスタンスで使う OpenCode バイナリと環境を選択します。", "settings.opencode.runtime.title": "ランタイム", diff --git a/packages/ui/src/lib/i18n/messages/ne/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ne/folderSelection.ts index 31f46c316..a6032582f 100644 --- a/packages/ui/src/lib/i18n/messages/ne/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ne/folderSelection.ts @@ -41,7 +41,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "रिपोजिटरी URL र गन्तव्य फोल्डर आवश्यक छ।", "folderSelection.actions.title": "फोल्डर खोल्नुहोस् वा सर्भर जडान गर्नुहोस्", "folderSelection.actions.subtitle": "स्थानीय फोल्डर खोल्नुहोस् वा CodeNomad सर्भरमा जडान गर्नुहोस्", - "folderSelection.actions.connectButton": "CodeNomad सर्भर जडान गर्नुहोस्", "folderSelection.advancedSettings": "उन्नत सेटिङहरू", "folderSelection.opencode": "OpenCode", @@ -63,36 +62,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "कोडिङ सुरु गर्न कार्यस्थान चयन गर्नुहोस्।", "folderSelection.tabs.local": "स्थानीय फोल्डरहरू", - "folderSelection.tabs.servers": "सर्भरहरू", - "folderSelection.servers.title": "बचत गरिएका सर्भरहरू", - "folderSelection.servers.subtitle": "नयाँ विन्डोमा सुरक्षित गरिएको रिमोट CodeNomad सर्भर खोल्नुहोस्", - "folderSelection.servers.count": "{count} सर्भरहरू", - "folderSelection.servers.empty.title": "कुनै बचत गरिएका सर्भरहरू छैनन्", - "folderSelection.servers.empty.description": "यो उपकरणबाट छिटो पुन: जडान गर्न रिमोट सर्भर थप्नुहोस्", - "folderSelection.servers.connectTitle": "सर्भरमा जडान गर्नुहोस्", - "folderSelection.servers.connectSubtitle": "रिमोट CodeNomad सर्भर बचत गर्नुहोस् र यसलाई नयाँ विन्डोमा खोल्नुहोस्", - "folderSelection.servers.connectButton": "सर्भरमा जडान गर्नुहोस्", - "folderSelection.servers.remove": "बचत गरिएको सर्भर हटाउनुहोस्", - "folderSelection.servers.skipTls": "स्व-हस्ताक्षरित TLS", - "folderSelection.servers.errorTitle": "रिमोट जडान असफल भयो", - "folderSelection.servers.dialog.title": "सर्भरमा जडान गर्नुहोस्", - "folderSelection.servers.dialog.description": "रिमोट CodeNomad सर्भर थप्नुहोस् र वैकल्पिक रूपमा यसलाई तुरुन्तै खोल्नुहोस्।", - "folderSelection.servers.dialog.name": "सर्भरको नाम", - "folderSelection.servers.dialog.namePlaceholder": "उत्पादन सर्भर", - "folderSelection.servers.dialog.url": "सर्भर URL", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", - "folderSelection.servers.dialog.skipTls": "स्व-हस्ताक्षरित प्रमाणपत्रहरूको लागि TLS प्रमाणीकरण छोड्नुहोस्।", - "folderSelection.servers.dialog.cancel": "रद्द गर्नुहोस्", - "folderSelection.servers.dialog.save": "बचत गर्नुहोस्", - "folderSelection.servers.dialog.connect": "जडान गर्नुहोस्", - "folderSelection.servers.dialog.connecting": "जडान गर्दै...", - "folderSelection.servers.dialog.errorRequired": "सर्भरको नाम र URL आवश्यक छ।", - "folderSelection.servers.dialog.errorConnect": "रिमोट सर्भरमा जडान गर्न सकिएन।", - "folderSelection.servers.certificateInstall.title": "स्थानीय प्रमाणपत्र स्थापना गर्नुहोस्", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad लाई स्व-हस्ताक्षरित HTTPS रिमोट विन्डोहरू खोल्न स्थानीय प्रमाणपत्र स्थापना गर्न आवश्यक छ। यो प्रमाणपत्र तपाईंको मेसिनमा स्थानीय डेस्कटप प्रोक्सी ट्राफिकको लागि मात्र प्रयोग गरिन्छ। तपाईंको अपरेटिङ सिस्टमले यसपछि दोस्रो प्रमाणपत्र प्रम्प्ट देखाउन सक्छ।", - "folderSelection.servers.certificateInstall.confirmLabel": "जारी राख्नुहोस्", - "folderSelection.servers.certificateInstall.cancelLabel": "रद्द गर्नुहोस्", - "folderSelection.servers.certificateInstall.cancelled": "CodeNomad लाई स्व-हस्ताक्षरित HTTPS रिमोट विन्डोहरू खोल्न सक्नु अघि स्थानीय प्रमाणपत्र विश्वास गरिनु पर्छ।", "folderSelection.sidecars.button": "SideCar खोल्नुहोस्", "projectRenameDialog.title": "कार्यस्थान पुन: नामकरण गर्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ne/index.ts b/packages/ui/src/lib/i18n/messages/ne/index.ts index 634928295..78d7f8285 100644 --- a/packages/ui/src/lib/i18n/messages/ne/index.ts +++ b/packages/ui/src/lib/i18n/messages/ne/index.ts @@ -9,7 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -31,6 +31,6 @@ export const neMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, - remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/ne/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ne/remoteAccess.ts deleted file mode 100644 index 297674566..000000000 --- a/packages/ui/src/lib/i18n/messages/ne/remoteAccess.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "रिमोट ह्यान्डोभर", - "remoteAccess.title": "CodeNomad सँग टाढैबाट (Remotely) जडान गर्नुहोस्", - "remoteAccess.subtitle": "अर्को उपकरणबाट CodeNomad खोल्न तलका ठेगानाहरू प्रयोग गर्नुहोस्।", - "remoteAccess.close": "रिमोट पहुँच बन्द गर्नुहोस्", - "remoteAccess.refresh": "रिफ्रेस", - - "remoteAccess.sections.listeningMode.label": "लिस्निङ मोड (Listening mode)", - "remoteAccess.sections.listeningMode.help": "सबै इन्टरफेस वा केवल localhost मा बाइन्ड गरेर रिमोट ह्यान्डोभरलाई अनुमति दिनुहोस् वा सीमित गर्नुहोस्।", - "remoteAccess.toggle.on": "अन", - "remoteAccess.toggle.off": "अफ", - "remoteAccess.toggle.title": "अन्य IP हरूबाट जडान अनुमति दिनुहोस्", - "remoteAccess.toggle.caption.all": "0.0.0.0 मा बाइन्ड गर्दै", - "remoteAccess.toggle.caption.local": "127.0.0.1 मा बाइन्ड गर्दै", - "remoteAccess.toggle.note": "यसलाई परिवर्तन गर्दा रिस्टार्ट आवश्यक पर्छ र अस्थायी रूपमा सबै सक्रिय उदाहरणहरू रोकिन्छ। सर्भर रिस्टार्ट भएपछि तलका ठेगानाहरू साझा गर्नुहोस्।", - "remoteAccess.listeningMode.restartConfirm.message": "लिस्निङ मोड लागू गर्न रिस्टार्ट गर्ने? यसले चलिरहेका सबै उदाहरणहरू रोक्नेछ।", - "remoteAccess.listeningMode.restartConfirm.title.all": "अन्य उपकरणहरूको लागि खोल्नुहोस्", - "remoteAccess.listeningMode.restartConfirm.title.local": "यस उपकरणमा सीमित गर्नुहोस्", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "अहिले रिस्टार्ट गर्नुहोस्", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "रद्द गर्नुहोस्", - "remoteAccess.restart.errorManual": "स्वत: रिस्टार्ट गर्न असमर्थ। कृपया परिवर्तन लागू गर्न एप रिस्टार्ट गर्नुहोस्।", - - "remoteAccess.sections.serverPassword.label": "सर्भर पासवर्ड", - "remoteAccess.sections.serverPassword.help": "रिमोट ह्यान्डोभरका लागि पासवर्ड आवश्यक पर्छ। अन्य उपकरणहरूबाट लगइन सक्षम गर्न एउटा पासवर्ड सेट गर्नुहोस्।", - "remoteAccess.authStatus.unavailable": "प्रमाणीकरण स्थिति उपलब्ध छैन।", - "remoteAccess.username": "प्रयोगकर्ता नाम: {username}", - "remoteAccess.password.status.set": "रिमोट पहुँचको लागि पासवर्ड सेट गरिएको छ।", - "remoteAccess.password.status.unset": "अझै कुनै पासवर्ड सेट गरिएको छैन। रिमोट लगइन अनुमति दिन एउटा सेट गर्नुहोस्।", - "remoteAccess.password.actions.cancel": "रद्द गर्नुहोस्", - "remoteAccess.password.actions.change": "पासवर्ड परिवर्तन गर्नुहोस्", - "remoteAccess.password.actions.set": "पासवर्ड सेट गर्नुहोस्", - "remoteAccess.password.form.newPassword": "नयाँ पासवर्ड", - "remoteAccess.password.form.confirmPassword": "पासवर्ड पुष्टि गर्नुहोस्", - "remoteAccess.password.form.placeholder": "कम्तिमा ८ अक्षरहरू", - "remoteAccess.password.error.tooShort": "पासवर्ड कम्तिमा ८ अक्षरको हुनुपर्छ।", - "remoteAccess.password.error.mismatch": "पासवर्डहरू मेल खाएनन्।", - "remoteAccess.password.save.saving": "बचत गर्दै...", - "remoteAccess.password.save.label": "पासवर्ड बचत गर्नुहोस्", - - "remoteAccess.sections.addresses.label": "पहुँचयोग्य ठेगानाहरू", - "remoteAccess.sections.addresses.help": "नियन्त्रण लिन अर्को मेसिनबाट सुरु गर्नुहोस् वा स्क्यान गर्नुहोस्।", - "remoteAccess.addresses.loading": "ठेगानाहरू लोड गर्दै...", - "remoteAccess.addresses.none": "अझै कुनै ठेगानाहरू उपलब्ध छैनन्।", - "remoteAccess.addresses.actions.showOther": "अन्य {count} ठेगानाहरू देखाउनुहोस्", - "remoteAccess.addresses.actions.hideOther": "अन्य ठेगानाहरू लुकाउनुहोस्", - "remoteAccess.address.scope.network": "नेटवर्क", - "remoteAccess.address.scope.loopback": "लूपब्याक", - "remoteAccess.address.scope.internal": "आन्तरिक", - "remoteAccess.address.open": "खोल्नुहोस्", - "remoteAccess.address.showQr": "QR देखाउनुहोस्", - "remoteAccess.address.hideQr": "QR लुकाउनुहोस्", - "remoteAccess.address.qrAlt": "{url} को लागि QR", -} as const diff --git a/packages/ui/src/lib/i18n/messages/ne/remoteControl.ts b/packages/ui/src/lib/i18n/messages/ne/remoteControl.ts new file mode 100644 index 000000000..2a95a45b7 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ne/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "रिमोट कन्ट्रोल", + "remoteControl.description": "नेटवर्क पोर्ट नखोली अर्को उपकरणबाट स्थानीय CodeNomad सत्रहरू जारी राख्नुहोस्।", + "remoteControl.refresh": "रिफ्रेस", + "remoteControl.state.stopped": "रिमोट कन्ट्रोल बन्द छ", + "remoteControl.state.connecting": "रिलेसँग जडान हुँदैछ…", + "remoteControl.state.connected": "रिमोट जडानका लागि तयार", + "remoteControl.state.reconnecting": "पुनः जडान हुँदैछ…", + "remoteControl.state.error": "रिले जडान असफल भयो", + "remoteControl.status.localOnly": "CodeNomad यस उपकरणमा मात्र उपलब्ध छ।", + "remoteControl.status.hostOnly": "Remote Control लाई CodeNomad होस्टबाट व्यवस्थापन गर्नुहोस्।", + "remoteControl.start": "रिमोट कन्ट्रोल सुरु गर्नुहोस्", + "remoteControl.newLink": "नयाँ जोडी लिंक बनाउनुहोस्", + "remoteControl.stop": "रिमोट कन्ट्रोल रोक्नुहोस्", + "remoteControl.pairing.title": "अर्को उपकरण जडान गर्नुहोस्", + "remoteControl.pairing.description": "QR कोड स्क्यान गर्नुहोस् वा अर्को उपकरणमा एकपटक प्रयोग हुने लिंक खोल्नुहोस्।", + "remoteControl.pairing.qrAlt": "रिमोट कन्ट्रोल जोडी QR कोड", + "remoteControl.pairing.copy": "लिंक प्रतिलिपि गर्नुहोस्", + "remoteControl.pairing.copied": "प्रतिलिपि भयो", + "remoteControl.pairing.expires": "यो लिंक {time} मा समाप्त हुन्छ।", + "remoteControl.devices.title": "जोडिएका उपकरणहरू", + "remoteControl.devices.description": "रिमोट कन्ट्रोल चल्दा यी उपकरणहरूले CodeNomad होस्ट पहुँच गर्न सक्छन्।", + "remoteControl.devices.empty": "अहिलेसम्म कुनै उपकरण जोडिएको छैन।", + "remoteControl.devices.lastSeen": "अन्तिम पटक: {time}", + "remoteControl.devices.revoke": "हटाउनुहोस्", +} as const diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 3ab2a000d..6025af691 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "लामो सहायक जवाफहरू रोक्नुहोस्", "settings.behavior.holdLongAssistantReplies.subtitle": "स्ट्रिमिङ जवाफ दृश्यभन्दा बाहिर जाँदा स्वचालित पछ्याइ रोक्नुहोस्।", "settings.nav.notifications": "सूचनाहरू", - "settings.nav.remote": "रिमोट पहुँच", + "settings.nav.remote": "रिमोट कन्ट्रोल", "settings.nav.speech": "वाचन (Speech)", "settings.nav.providers": "प्रदायकहरू", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "सूचनाहरू सक्षम गरियो", "settings.notifications.status.disabled": "सूचनाहरू अक्षम गरियो", "settings.notifications.status.unsupported": "सूचनाहरू असमर्थित", - "settings.section.remote.title": "रिमोट पहुँच", - "settings.section.remote.subtitle": "यो सर्भर तपाईंको नेटवर्कमा कसरी देखिन्छ र पहुँच प्रमाणहरू समीक्षा गर्नुहोस्।", + "settings.section.remote.title": "रिमोट कन्ट्रोल", + "settings.section.remote.subtitle": "सुरक्षित आउटबाउन्ड रिले मार्फत अर्को उपकरणबाट यस उपकरणका सत्रहरू जारी राख्नुहोस्।", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "नयाँ उदाहरणहरूको लागि OpenCode बाइनरी र वातावरण छनौट गर्नुहोस्।", "settings.opencode.runtime.title": "रनटाइम (Runtime)", diff --git a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts index bae2807ac..a05acb8e5 100644 --- a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts @@ -41,7 +41,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "URL репозитория и папка назначения обязательны.", "folderSelection.actions.title": "Открыть папку или подключить сервер", "folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad", - "folderSelection.actions.connectButton": "Подключить сервер CodeNomad", "folderSelection.advancedSettings": "Расширенные настройки", "folderSelection.opencode": "OpenCode", @@ -63,36 +62,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.", "folderSelection.tabs.local": "Локальные папки", - "folderSelection.tabs.servers": "Серверы", - "folderSelection.servers.title": "Сохраненные серверы", - "folderSelection.servers.subtitle": "Откройте сохраненный удаленный сервер CodeNomad в новом окне", - "folderSelection.servers.count": "{count} серверов", - "folderSelection.servers.empty.title": "Нет сохраненных серверов", - "folderSelection.servers.empty.description": "Добавьте удаленный сервер, чтобы быстро подключаться к нему с этого устройства", - "folderSelection.servers.connectTitle": "Подключиться к серверу", - "folderSelection.servers.connectSubtitle": "Сохраните удаленный сервер CodeNomad и откройте его в новом окне", - "folderSelection.servers.connectButton": "Подключиться к серверу", - "folderSelection.servers.remove": "Удалить сохраненный сервер", - "folderSelection.servers.skipTls": "Самоподписанный TLS", - "folderSelection.servers.errorTitle": "Ошибка удаленного подключения", - "folderSelection.servers.dialog.title": "Подключиться к серверу", - "folderSelection.servers.dialog.description": "Добавьте удаленный сервер CodeNomad и при желании сразу откройте его.", - "folderSelection.servers.dialog.name": "Имя сервера", - "folderSelection.servers.dialog.namePlaceholder": "Продакшн сервер", - "folderSelection.servers.dialog.url": "URL сервера", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", - "folderSelection.servers.dialog.skipTls": "Пропустить проверку TLS для самоподписанных сертификатов.", - "folderSelection.servers.dialog.cancel": "Отмена", - "folderSelection.servers.dialog.save": "Сохранить", - "folderSelection.servers.dialog.connect": "Подключиться", - "folderSelection.servers.dialog.connecting": "Подключение...", - "folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.", - "folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.", - "folderSelection.servers.certificateInstall.title": "Установить локальный сертификат", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.", - "folderSelection.servers.certificateInstall.confirmLabel": "Продолжить", - "folderSelection.servers.certificateInstall.cancelLabel": "Отмена", - "folderSelection.servers.certificateInstall.cancelled": "CodeNomad должен доверять локальному сертификату, прежде чем сможет открывать удаленные HTTPS-окна с самоподписанным сертификатом.", "folderSelection.sidecars.button": "Открыть SideCar", "projectRenameDialog.title": "Переименовать рабочее пространство", diff --git a/packages/ui/src/lib/i18n/messages/ru/index.ts b/packages/ui/src/lib/i18n/messages/ru/index.ts index 4188ca557..73c60bbc8 100644 --- a/packages/ui/src/lib/i18n/messages/ru/index.ts +++ b/packages/ui/src/lib/i18n/messages/ru/index.ts @@ -9,7 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -31,6 +31,6 @@ export const ruMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, - remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts deleted file mode 100644 index a711e2f65..000000000 --- a/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "Удаленная передача управления", - "remoteAccess.title": "Подключитесь к CodeNomad удаленно", - "remoteAccess.subtitle": "Используйте адреса ниже, чтобы открыть CodeNomad с другого устройства.", - "remoteAccess.close": "Закрыть удаленный доступ", - "remoteAccess.refresh": "Обновить", - - "remoteAccess.sections.listeningMode.label": "Режим прослушивания", - "remoteAccess.sections.listeningMode.help": "Разрешайте или ограничивайте удаленную передачу управления, привязываясь ко всем интерфейсам или только к localhost.", - "remoteAccess.toggle.on": "Вкл", - "remoteAccess.toggle.off": "Выкл", - "remoteAccess.toggle.title": "Разрешить подключения с других IP", - "remoteAccess.toggle.caption.all": "Привязка к 0.0.0.0", - "remoteAccess.toggle.caption.local": "Привязка к 127.0.0.1", - "remoteAccess.toggle.note": "Изменение требует перезапуска и временно остановит все активные экземпляры. Поделитесь адресами ниже после перезапуска сервера.", - "remoteAccess.listeningMode.restartConfirm.message": "Перезапустить, чтобы применить режим прослушивания? Это остановит все запущенные экземпляры.", - "remoteAccess.listeningMode.restartConfirm.title.all": "Открыть для других устройств", - "remoteAccess.listeningMode.restartConfirm.title.local": "Ограничить этим устройством", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Перезапустить сейчас", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Отмена", - "remoteAccess.restart.errorManual": "Не удалось перезапустить автоматически. Перезапустите приложение, чтобы применить изменение.", - - "remoteAccess.sections.serverPassword.label": "Пароль сервера", - "remoteAccess.sections.serverPassword.help": "Для удаленной передачи управления требуется пароль. Установите запоминающийся пароль, чтобы разрешить вход с других устройств.", - "remoteAccess.authStatus.unavailable": "Статус аутентификации недоступен.", - "remoteAccess.username": "Имя пользователя: {username}", - "remoteAccess.password.status.set": "Для удаленного доступа установлен пароль.", - "remoteAccess.password.status.unset": "Пока не установлен запоминающийся пароль. Установите его, чтобы разрешить вход при удаленной передаче управления.", - "remoteAccess.password.actions.cancel": "Отмена", - "remoteAccess.password.actions.change": "Изменить пароль", - "remoteAccess.password.actions.set": "Установить пароль", - "remoteAccess.password.form.newPassword": "Новый пароль", - "remoteAccess.password.form.confirmPassword": "Подтвердите пароль", - "remoteAccess.password.form.placeholder": "Не менее 8 символов", - "remoteAccess.password.error.tooShort": "Пароль должен быть не короче 8 символов.", - "remoteAccess.password.error.mismatch": "Пароли не совпадают.", - "remoteAccess.password.save.saving": "Сохранение…", - "remoteAccess.password.save.label": "Сохранить пароль", - - "remoteAccess.sections.addresses.label": "Доступные адреса", - "remoteAccess.sections.addresses.help": "Откройте или отсканируйте с другой машины, чтобы передать управление.", - "remoteAccess.addresses.loading": "Загрузка адресов…", - "remoteAccess.addresses.none": "Пока нет доступных адресов.", - "remoteAccess.addresses.actions.showOther": "Показать еще {count} адресов", - "remoteAccess.addresses.actions.hideOther": "Скрыть остальные адреса", - "remoteAccess.address.scope.network": "Сеть", - "remoteAccess.address.scope.loopback": "Локальный loopback", - "remoteAccess.address.scope.internal": "Внутренний", - "remoteAccess.address.open": "Открыть", - "remoteAccess.address.showQr": "Показать QR", - "remoteAccess.address.hideQr": "Скрыть QR", - "remoteAccess.address.qrAlt": "QR для {url}", -} as const diff --git a/packages/ui/src/lib/i18n/messages/ru/remoteControl.ts b/packages/ui/src/lib/i18n/messages/ru/remoteControl.ts new file mode 100644 index 000000000..6b6d27c63 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ru/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Удалённое управление", + "remoteControl.description": "Продолжайте локальные сеансы CodeNomad с другого устройства, не открывая сетевые порты.", + "remoteControl.refresh": "Обновить", + "remoteControl.state.stopped": "Удалённое управление выключено", + "remoteControl.state.connecting": "Подключение к ретранслятору…", + "remoteControl.state.connected": "Готово к удалённым подключениям", + "remoteControl.state.reconnecting": "Повторное подключение…", + "remoteControl.state.error": "Не удалось подключиться к ретранслятору", + "remoteControl.status.localOnly": "CodeNomad доступен только на этом устройстве.", + "remoteControl.status.hostOnly": "Управляйте Remote Control на основном устройстве CodeNomad.", + "remoteControl.start": "Запустить удалённое управление", + "remoteControl.newLink": "Создать новую ссылку сопряжения", + "remoteControl.stop": "Остановить удалённое управление", + "remoteControl.pairing.title": "Подключить другое устройство", + "remoteControl.pairing.description": "Отсканируйте QR-код или откройте одноразовую ссылку на другом устройстве.", + "remoteControl.pairing.qrAlt": "QR-код сопряжения удалённого управления", + "remoteControl.pairing.copy": "Копировать ссылку", + "remoteControl.pairing.copied": "Скопировано", + "remoteControl.pairing.expires": "Ссылка истекает в {time}.", + "remoteControl.devices.title": "Сопряжённые устройства", + "remoteControl.devices.description": "Эти устройства могут обращаться к хосту, пока удалённое управление запущено.", + "remoteControl.devices.empty": "Сопряжённых устройств пока нет.", + "remoteControl.devices.lastSeen": "Последняя активность: {time}", + "remoteControl.devices.revoke": "Отозвать", +} as const diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 4b9b1e292..45028e2d9 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Удерживать длинные ответы ассистента", "settings.behavior.holdLongAssistantReplies.subtitle": "Прекращать автоматическое следование, когда потоковый ответ выходит за пределы окна.", "settings.nav.notifications": "Уведомления", - "settings.nav.remote": "Удалённый доступ", + "settings.nav.remote": "Удалённое управление", "settings.nav.speech": "Речь", "settings.nav.providers": "Провайдеры", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Уведомления включены", "settings.notifications.status.disabled": "Уведомления отключены", "settings.notifications.status.unsupported": "Уведомления не поддерживаются", - "settings.section.remote.title": "Удалённый доступ", - "settings.section.remote.subtitle": "Проверьте, как этот сервер доступен в сети, и защитите учётные данные доступа.", + "settings.section.remote.title": "Удалённое управление", + "settings.section.remote.subtitle": "Безопасно продолжайте сеансы этого устройства с другого через исходящий ретранслятор.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Выберите бинарник OpenCode и окружение для новых экземпляров.", "settings.opencode.runtime.title": "Среда выполнения", diff --git a/packages/ui/src/lib/i18n/messages/tr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/tr/folderSelection.ts index 89645e51a..c8bc131da 100644 --- a/packages/ui/src/lib/i18n/messages/tr/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/tr/folderSelection.ts @@ -36,7 +36,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "Depo URL'si ve hedef klasör zorunludur.", "folderSelection.actions.title": "Klasör Aç veya Sunucuya Bağlan", "folderSelection.actions.subtitle": "Yerel klasör aç veya bir CodeNomad sunucusuna bağlan", - "folderSelection.actions.connectButton": "CodeNomad Sunucusuna Bağlan", "folderSelection.advancedSettings": "Gelişmiş Ayarlar", "folderSelection.opencode": "OpenCode", "folderSelection.hints.navigate": "Gezin", @@ -52,36 +51,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Workspace Seç", "folderSelection.dialog.description": "Kod yazmaya başlamak için workspace seçin.", "folderSelection.tabs.local": "Yerel Klasörler", - "folderSelection.tabs.servers": "Sunucular", - "folderSelection.servers.title": "Kayıtlı Sunucular", - "folderSelection.servers.subtitle": "Kayıtlı bir uzak CodeNomad sunucusunu yeni pencerede aç", - "folderSelection.servers.count": "{count} Sunucu", - "folderSelection.servers.empty.title": "Kayıtlı Sunucu Yok", - "folderSelection.servers.empty.description": "Bu cihazdan hızlıca yeniden bağlanmak için bir uzak sunucu ekleyin", - "folderSelection.servers.connectTitle": "Sunucuya Bağlan", - "folderSelection.servers.connectSubtitle": "Bir uzak CodeNomad sunucusunu kaydedin ve yeni pencerede açın", - "folderSelection.servers.connectButton": "Sunucuya Bağlan", - "folderSelection.servers.remove": "Kayıtlı sunucuyu kaldır", - "folderSelection.servers.skipTls": "Kendinden imzalı TLS", - "folderSelection.servers.errorTitle": "Uzak Bağlantı Başarısız", - "folderSelection.servers.dialog.title": "Sunucuya Bağlan", - "folderSelection.servers.dialog.description": "Bir uzak CodeNomad sunucusu ekleyin ve isterseniz hemen açın.", - "folderSelection.servers.dialog.name": "Sunucu adı", - "folderSelection.servers.dialog.namePlaceholder": "Production Sunucusu", - "folderSelection.servers.dialog.url": "Sunucu URL'si", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", - "folderSelection.servers.dialog.skipTls": "Kendinden imzalı sertifikalar için TLS doğrulamasını atla.", - "folderSelection.servers.dialog.cancel": "İptal", - "folderSelection.servers.dialog.save": "Kaydet", - "folderSelection.servers.dialog.connect": "Bağlan", - "folderSelection.servers.dialog.connecting": "Bağlanıyor...", - "folderSelection.servers.dialog.errorRequired": "Sunucu adı ve URL zorunludur.", - "folderSelection.servers.dialog.errorConnect": "Uzak sunucuya bağlanılamadı.", - "folderSelection.servers.certificateInstall.title": "Yerel Sertifikayı Kur", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad'ın kendinden imzalı HTTPS uzak pencerelerini açmak için yerel bir sertifika kurması gerekiyor. Bu sertifika yalnızca makinenizdeki yerel masaüstü proxy trafiği için kullanılır. İşletim sisteminiz bundan sonra ikinci bir sertifika istemi gösterebilir.", - "folderSelection.servers.certificateInstall.confirmLabel": "Devam et", - "folderSelection.servers.certificateInstall.cancelLabel": "İptal", - "folderSelection.servers.certificateInstall.cancelled": "CodeNomad'ın kendinden imzalı HTTPS uzak pencerelerini açabilmesi için yerel sertifikanın güvenilir olması gerekir.", "folderSelection.sidecars.button": "SideCar'ı Aç", "projectRenameDialog.title": "Workspace'i yeniden adlandır", "projectRenameDialog.description.withLabel": "\"{label}\" için workspace adını güncelleyin.", diff --git a/packages/ui/src/lib/i18n/messages/tr/index.ts b/packages/ui/src/lib/i18n/messages/tr/index.ts index c1ed1fe7e..829dfb4e3 100644 --- a/packages/ui/src/lib/i18n/messages/tr/index.ts +++ b/packages/ui/src/lib/i18n/messages/tr/index.ts @@ -10,7 +10,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -28,7 +28,7 @@ export const trMessages = mergeMessageParts( logMessages, markdownMessages, messagingMessages, - remoteAccessMessages, + remoteControlMessages, sessionMessages, settingsMessages, timeMessages, diff --git a/packages/ui/src/lib/i18n/messages/tr/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/tr/remoteAccess.ts deleted file mode 100644 index 09e4f9209..000000000 --- a/packages/ui/src/lib/i18n/messages/tr/remoteAccess.ts +++ /dev/null @@ -1,50 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "Uzak devir", - "remoteAccess.title": "CodeNomad'a uzaktan bağlan", - "remoteAccess.subtitle": "CodeNomad'ı başka bir cihazdan açmak için aşağıdaki adresleri kullanın.", - "remoteAccess.close": "Uzak erişimi kapat", - "remoteAccess.refresh": "Yenile", - "remoteAccess.sections.listeningMode.label": "Dinleme modu", - "remoteAccess.sections.listeningMode.help": "Sunucu tüm arayüzlerde ya da yalnızca localhost'ta dinleyerek uzak devirlere izin verir veya bunları sınırlar.", - "remoteAccess.toggle.on": "Açık", - "remoteAccess.toggle.off": "Kapalı", - "remoteAccess.toggle.title": "Diğer IP'lerden bağlantılara izin ver", - "remoteAccess.toggle.caption.all": "0.0.0.0 üzerinde dinleniyor", - "remoteAccess.toggle.caption.local": "127.0.0.1 üzerinde dinleniyor", - "remoteAccess.toggle.note": "Bu ayarı değiştirmek yeniden başlatma gerektirir ve geçici olarak tüm etkin instance'ları durdurur. Sunucu yeniden başlatıldıktan sonra aşağıdaki adresleri paylaşın.", - "remoteAccess.listeningMode.restartConfirm.message": "Dinleme modunu uygulamak için yeniden başlatılsın mı? Bu, çalışan tüm instance'ları durdurur.", - "remoteAccess.listeningMode.restartConfirm.title.all": "Diğer cihazlara aç", - "remoteAccess.listeningMode.restartConfirm.title.local": "Bu cihazla sınırla", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Şimdi yeniden başlat", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "İptal", - "remoteAccess.restart.errorManual": "Otomatik olarak yeniden başlatılamıyor. Değişikliği uygulamak için uygulamayı yeniden başlatın.", - "remoteAccess.sections.serverPassword.label": "Sunucu şifresi", - "remoteAccess.sections.serverPassword.help": "Uzak devirler için şifre gerekir. Diğer cihazlardan oturum açmayı etkinleştirmek için akılda kalıcı bir şifre belirleyin.", - "remoteAccess.authStatus.unavailable": "Kimlik doğrulama durumu kullanılamıyor.", - "remoteAccess.username": "Kullanıcı adı: {username}", - "remoteAccess.password.status.set": "Uzak erişim için bir şifre belirlendi.", - "remoteAccess.password.status.unset": "Henüz akılda kalıcı bir şifre belirlenmedi. Uzak devir oturumlarına izin vermek için bir şifre belirleyin.", - "remoteAccess.password.actions.cancel": "İptal", - "remoteAccess.password.actions.change": "Şifreyi değiştir", - "remoteAccess.password.actions.set": "Şifre belirle", - "remoteAccess.password.form.newPassword": "Yeni şifre", - "remoteAccess.password.form.confirmPassword": "Şifreyi onayla", - "remoteAccess.password.form.placeholder": "En az 8 karakter", - "remoteAccess.password.error.tooShort": "Şifre en az 8 karakter olmalıdır.", - "remoteAccess.password.error.mismatch": "Şifreler eşleşmiyor.", - "remoteAccess.password.save.saving": "Kaydediliyor…", - "remoteAccess.password.save.label": "Şifreyi kaydet", - "remoteAccess.sections.addresses.label": "Erişilebilir adresler", - "remoteAccess.sections.addresses.help": "Kontrolü devretmek için başka bir makineden başlatın veya tarayın.", - "remoteAccess.addresses.loading": "Adresler yükleniyor…", - "remoteAccess.addresses.none": "Henüz kullanılabilir adres yok.", - "remoteAccess.addresses.actions.showOther": "Diğer {count} adresi göster", - "remoteAccess.addresses.actions.hideOther": "Diğer adresleri gizle", - "remoteAccess.address.scope.network": "Ağ", - "remoteAccess.address.scope.loopback": "Loopback", - "remoteAccess.address.scope.internal": "Dahili", - "remoteAccess.address.open": "Aç", - "remoteAccess.address.showQr": "QR göster", - "remoteAccess.address.hideQr": "QR gizle", - "remoteAccess.address.qrAlt": "{url} için QR kodu", -} as const diff --git a/packages/ui/src/lib/i18n/messages/tr/remoteControl.ts b/packages/ui/src/lib/i18n/messages/tr/remoteControl.ts new file mode 100644 index 000000000..d0329357b --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/tr/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Uzaktan Kontrol", + "remoteControl.description": "Ağ portlarını açmadan yerel CodeNomad oturumlarını başka bir cihazdan sürdürün.", + "remoteControl.refresh": "Yenile", + "remoteControl.state.stopped": "Uzaktan Kontrol kapalı", + "remoteControl.state.connecting": "Aktarıcıya bağlanılıyor…", + "remoteControl.state.connected": "Uzak bağlantılar için hazır", + "remoteControl.state.reconnecting": "Yeniden bağlanılıyor…", + "remoteControl.state.error": "Aktarıcı bağlantısı başarısız", + "remoteControl.status.localOnly": "CodeNomad yalnızca bu cihazda kullanılabilir.", + "remoteControl.status.hostOnly": "Remote Control’ü CodeNomad ana cihazından yönetin.", + "remoteControl.start": "Uzaktan Kontrolü başlat", + "remoteControl.newLink": "Yeni eşleştirme bağlantısı oluştur", + "remoteControl.stop": "Uzaktan Kontrolü durdur", + "remoteControl.pairing.title": "Başka bir cihaz bağla", + "remoteControl.pairing.description": "QR kodunu tarayın veya tek kullanımlık bağlantıyı diğer cihazda açın.", + "remoteControl.pairing.qrAlt": "Uzaktan Kontrol eşleştirme QR kodu", + "remoteControl.pairing.copy": "Bağlantıyı kopyala", + "remoteControl.pairing.copied": "Kopyalandı", + "remoteControl.pairing.expires": "Bu bağlantı {time} saatinde sona erer.", + "remoteControl.devices.title": "Eşleştirilmiş cihazlar", + "remoteControl.devices.description": "Uzaktan Kontrol çalışırken bu cihazlar CodeNomad ana makinesine erişebilir.", + "remoteControl.devices.empty": "Henüz eşleştirilmiş cihaz yok.", + "remoteControl.devices.lastSeen": "Son görülme: {time}", + "remoteControl.devices.revoke": "İptal et", +} as const diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index a8128f8ec..cd3388015 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -115,7 +115,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Uzun asistan yanıtlarında takibi durdur", "settings.behavior.holdLongAssistantReplies.subtitle": "Streaming bir yanıt görünüm alanını aşınca otomatik takibi durdurur.", "settings.nav.notifications": "Bildirimler", - "settings.nav.remote": "Uzak Erişim", + "settings.nav.remote": "Uzaktan Kontrol", "settings.nav.speech": "Konuşma", "settings.nav.providers": "Provider'lar", "settings.nav.opencode": "OpenCode", @@ -216,8 +216,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Bildirimler etkin", "settings.notifications.status.disabled": "Bildirimler devre dışı", "settings.notifications.status.unsupported": "Bildirimler desteklenmiyor", - "settings.section.remote.title": "Uzak Erişim", - "settings.section.remote.subtitle": "Bu sunucunun ağınızda nasıl sunulduğunu inceleyin ve erişim kimlik bilgilerini güvence altına alın.", + "settings.section.remote.title": "Uzaktan Kontrol", + "settings.section.remote.subtitle": "Bu cihazdaki oturumları güvenli giden aktarıcı üzerinden başka bir cihazda sürdürün.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Yeni instance'lar için kullanılacak OpenCode binary'sini ve ortamını seçin.", "settings.opencode.runtime.title": "Runtime", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts index f168c2de8..861be836f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts @@ -41,7 +41,6 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "仓库 URL 和目标文件夹为必填项。", "folderSelection.actions.title": "打开文件夹或连接服务器", "folderSelection.actions.subtitle": "打开本地文件夹或连接到 CodeNomad 服务器", - "folderSelection.actions.connectButton": "连接 CodeNomad 服务器", "folderSelection.advancedSettings": "高级设置", "folderSelection.opencode": "OpenCode", @@ -63,36 +62,6 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "选择工作区以开始编码。", "folderSelection.tabs.local": "本地文件夹", - "folderSelection.tabs.servers": "服务器", - "folderSelection.servers.title": "已保存的服务器", - "folderSelection.servers.subtitle": "在新窗口中打开已保存的远程 CodeNomad 服务器", - "folderSelection.servers.count": "{count} 个服务器", - "folderSelection.servers.empty.title": "没有已保存的服务器", - "folderSelection.servers.empty.description": "添加远程服务器,以便在此设备上快速重新连接", - "folderSelection.servers.connectTitle": "连接到服务器", - "folderSelection.servers.connectSubtitle": "保存远程 CodeNomad 服务器并在新窗口中打开它", - "folderSelection.servers.connectButton": "连接到服务器", - "folderSelection.servers.remove": "删除已保存服务器", - "folderSelection.servers.skipTls": "自签名 TLS", - "folderSelection.servers.errorTitle": "远程连接失败", - "folderSelection.servers.dialog.title": "连接到服务器", - "folderSelection.servers.dialog.description": "添加远程 CodeNomad 服务器,并可选择立即打开。", - "folderSelection.servers.dialog.name": "服务器名称", - "folderSelection.servers.dialog.namePlaceholder": "生产服务器", - "folderSelection.servers.dialog.url": "服务器 URL", - "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", - "folderSelection.servers.dialog.skipTls": "为自签名证书跳过 TLS 验证。", - "folderSelection.servers.dialog.cancel": "取消", - "folderSelection.servers.dialog.save": "保存", - "folderSelection.servers.dialog.connect": "连接", - "folderSelection.servers.dialog.connecting": "连接中...", - "folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。", - "folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。", - "folderSelection.servers.certificateInstall.title": "安装本地证书", - "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。", - "folderSelection.servers.certificateInstall.confirmLabel": "继续", - "folderSelection.servers.certificateInstall.cancelLabel": "取消", - "folderSelection.servers.certificateInstall.cancelled": "CodeNomad 需要先信任本地证书,才能打开使用自签名 HTTPS 的远程窗口。", "folderSelection.sidecars.button": "打开 SideCar", "projectRenameDialog.title": "重命名工作区", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts index 5da404da6..416458335 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts @@ -9,7 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" -import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -31,6 +31,6 @@ export const zhHansMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, - remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts deleted file mode 100644 index 16a8b2656..000000000 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const remoteAccessMessages = { - "remoteAccess.eyebrow": "远程接管", - "remoteAccess.title": "远程连接到 CodeNomad", - "remoteAccess.subtitle": "使用下面的地址从其他设备打开 CodeNomad。", - "remoteAccess.close": "关闭远程访问", - "remoteAccess.refresh": "刷新", - - "remoteAccess.sections.listeningMode.label": "监听模式", - "remoteAccess.sections.listeningMode.help": "通过绑定到所有接口或仅 localhost 来允许或限制远程接管。", - "remoteAccess.toggle.on": "开", - "remoteAccess.toggle.off": "关", - "remoteAccess.toggle.title": "允许其他 IP 连接", - "remoteAccess.toggle.caption.all": "绑定到 0.0.0.0", - "remoteAccess.toggle.caption.local": "绑定到 127.0.0.1", - "remoteAccess.toggle.note": "更改此项需要重启,并会暂时停止所有活动实例。服务器重启后再分享下方地址。", - "remoteAccess.listeningMode.restartConfirm.message": "重启以应用监听模式?这将停止所有正在运行的实例。", - "remoteAccess.listeningMode.restartConfirm.title.all": "对其他设备开放", - "remoteAccess.listeningMode.restartConfirm.title.local": "仅限此设备", - "remoteAccess.listeningMode.restartConfirm.confirmLabel": "立即重启", - "remoteAccess.listeningMode.restartConfirm.cancelLabel": "取消", - "remoteAccess.restart.errorManual": "无法自动重启。请手动重启应用以应用更改。", - - "remoteAccess.sections.serverPassword.label": "服务器密码", - "remoteAccess.sections.serverPassword.help": "远程接管需要密码。设置一个易记的密码,以允许其他设备登录。", - "remoteAccess.authStatus.unavailable": "无法获取认证状态。", - "remoteAccess.username": "用户名:{username}", - "remoteAccess.password.status.set": "已为远程访问设置密码。", - "remoteAccess.password.status.unset": "尚未设置易记密码。设置后可允许远程接管登录。", - "remoteAccess.password.actions.cancel": "取消", - "remoteAccess.password.actions.change": "修改密码", - "remoteAccess.password.actions.set": "设置密码", - "remoteAccess.password.form.newPassword": "新密码", - "remoteAccess.password.form.confirmPassword": "确认密码", - "remoteAccess.password.form.placeholder": "至少 8 个字符", - "remoteAccess.password.error.tooShort": "密码至少需要 8 个字符。", - "remoteAccess.password.error.mismatch": "两次输入的密码不一致。", - "remoteAccess.password.save.saving": "正在保存…", - "remoteAccess.password.save.label": "保存密码", - - "remoteAccess.sections.addresses.label": "可访问地址", - "remoteAccess.sections.addresses.help": "从另一台设备打开或扫描,以接管控制权。", - "remoteAccess.addresses.loading": "正在加载地址…", - "remoteAccess.addresses.none": "暂时没有可用地址。", - "remoteAccess.addresses.actions.showOther": "显示另外 {count} 个地址", - "remoteAccess.addresses.actions.hideOther": "隐藏其他地址", - "remoteAccess.address.scope.network": "网络", - "remoteAccess.address.scope.loopback": "回环", - "remoteAccess.address.scope.internal": "内部", - "remoteAccess.address.open": "打开", - "remoteAccess.address.showQr": "显示二维码", - "remoteAccess.address.hideQr": "隐藏二维码", - "remoteAccess.address.qrAlt": "{url} 的二维码", -} as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/remoteControl.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteControl.ts new file mode 100644 index 000000000..015a6c785 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "远程控制", + "remoteControl.description": "无需开放网络端口,即可从其他设备继续本机 CodeNomad 会话。", + "remoteControl.refresh": "刷新", + "remoteControl.state.stopped": "远程控制已关闭", + "remoteControl.state.connecting": "正在连接中继…", + "remoteControl.state.connected": "已准备好远程连接", + "remoteControl.state.reconnecting": "正在重新连接…", + "remoteControl.state.error": "中继连接失败", + "remoteControl.status.localOnly": "CodeNomad 目前仅可在此设备上使用。", + "remoteControl.status.hostOnly": "请在 CodeNomad 主机上管理远程控制。", + "remoteControl.start": "启动远程控制", + "remoteControl.newLink": "创建新的配对链接", + "remoteControl.stop": "停止远程控制", + "remoteControl.pairing.title": "连接其他设备", + "remoteControl.pairing.description": "扫描二维码,或在其他设备上打开一次性链接。", + "remoteControl.pairing.qrAlt": "远程控制配对二维码", + "remoteControl.pairing.copy": "复制链接", + "remoteControl.pairing.copied": "已复制", + "remoteControl.pairing.expires": "此链接将在 {time} 过期。", + "remoteControl.devices.title": "已配对设备", + "remoteControl.devices.description": "远程控制运行时,这些设备可以访问此 CodeNomad 主机。", + "remoteControl.devices.empty": "尚未配对任何设备。", + "remoteControl.devices.lastSeen": "上次活动:{time}", + "remoteControl.devices.revoke": "撤销", +} as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 8bb6d1f61..920306fc3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "暂停跟随较长的助手回复", "settings.behavior.holdLongAssistantReplies.subtitle": "当流式回复超出视口时停止自动跟随。", "settings.nav.notifications": "通知", - "settings.nav.remote": "远程访问", + "settings.nav.remote": "远程控制", "settings.nav.speech": "语音", "settings.nav.providers": "提供商", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "通知已启用", "settings.notifications.status.disabled": "通知已禁用", "settings.notifications.status.unsupported": "不支持通知", - "settings.section.remote.title": "远程访问", - "settings.section.remote.subtitle": "查看此服务器如何暴露到网络,以及安全访问凭据。", + "settings.section.remote.title": "远程控制", + "settings.section.remote.subtitle": "通过安全的出站中继,从其他设备继续此设备上的会话。", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "选择新实例使用的 OpenCode 可执行文件和环境。", "settings.opencode.runtime.title": "运行时", diff --git a/packages/ui/src/lib/native/remote-window.ts b/packages/ui/src/lib/native/remote-window.ts deleted file mode 100644 index 5237d537e..000000000 --- a/packages/ui/src/lib/native/remote-window.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { invoke } from "@tauri-apps/api/core" -import type { RemoteServerProfile } from "../../../../server/src/api-types" -import { showConfirmDialog } from "../../stores/alerts" -import { tGlobal } from "../i18n" -import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env" - -export interface RemoteWindowOpenPayload { - id: string - name: string - baseUrl: string - entryUrl?: string - proxySessionId?: string - skipTlsVerify: boolean -} - -export async function openRemoteServerWindow( - profile: Pick, - entryUrl?: string, - proxySessionId?: string, -): Promise { - if (!canOpenRemoteWindows()) { - throw new Error("Remote server windows can only be opened from a local desktop window") - } - - const payload: RemoteWindowOpenPayload = { - id: profile.id, - name: profile.name, - baseUrl: profile.baseUrl, - entryUrl, - proxySessionId, - skipTlsVerify: profile.skipTlsVerify, - } - - if (isElectronHost()) { - const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI - if (typeof api?.openRemoteWindow === "function") { - await api.openRemoteWindow(payload) - return - } - } - - if (isTauriHost()) { - const requiresLocalCertificate = - proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://") - - if (requiresLocalCertificate) { - const needsInstall = await invoke("needs_local_certificate_install") - if (needsInstall) { - const accepted = await showConfirmDialog( - tGlobal("folderSelection.servers.certificateInstall.confirmMessage"), - { - title: tGlobal("folderSelection.servers.certificateInstall.title"), - variant: "warning", - confirmLabel: tGlobal("folderSelection.servers.certificateInstall.confirmLabel"), - cancelLabel: tGlobal("folderSelection.servers.certificateInstall.cancelLabel"), - }, - ) - - if (!accepted) { - throw new Error(tGlobal("folderSelection.servers.certificateInstall.cancelled")) - } - } - } - - await invoke("open_remote_window", { payload }) - return - } - - window.open(profile.baseUrl, "_blank", "noopener,noreferrer") -} diff --git a/packages/ui/src/lib/remote-access-addresses.test.ts b/packages/ui/src/lib/remote-access-addresses.test.ts deleted file mode 100644 index 7161d035c..000000000 --- a/packages/ui/src/lib/remote-access-addresses.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { splitRemoteAddresses } from "./remote-access-addresses" - -describe("splitRemoteAddresses", () => { - it("keeps the first remote address visible and collapses the rest", () => { - const result = splitRemoteAddresses([ - { ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" }, - { ip: "192.168.1.128", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.128:9898" }, - { ip: "172.24.96.1", family: "ipv4", scope: "external", remoteUrl: "https://172.24.96.1:9898" }, - ]) - - assert.equal(result.recommended?.ip, "192.168.1.128") - assert.deepEqual(result.hidden.map((address) => address.ip), ["172.24.96.1"]) - }) -}) diff --git a/packages/ui/src/lib/remote-access-addresses.ts b/packages/ui/src/lib/remote-access-addresses.ts deleted file mode 100644 index e5aa8eb88..000000000 --- a/packages/ui/src/lib/remote-access-addresses.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NetworkAddress } from "../../../server/src/api-types" - -export interface RemoteAddressGroups { - recommended: NetworkAddress | null - hidden: NetworkAddress[] -} - -export function splitRemoteAddresses(addresses: NetworkAddress[]): RemoteAddressGroups { - const remoteAddresses = addresses.filter((address) => address.scope !== "loopback") - return { - recommended: remoteAddresses[0] ?? null, - hidden: remoteAddresses.slice(1), - } -} diff --git a/packages/ui/src/lib/runtime-env.test.ts b/packages/ui/src/lib/runtime-env.test.ts index e9484fc98..9012bb1be 100644 --- a/packages/ui/src/lib/runtime-env.test.ts +++ b/packages/ui/src/lib/runtime-env.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { canOpenRemoteWindows, canRestartCli, canUseNativeDialogs, isLocalTauriHost, usesClientState, type RuntimeEnvironment } from "./runtime-env.ts" +import { canRestartCli, canUseNativeDialogs, isLocalTauriHost, usesClientState, type RuntimeEnvironment } from "./runtime-env.ts" const environment = (host: RuntimeEnvironment["host"], windowContext: RuntimeEnvironment["windowContext"]) => ({ host, @@ -12,7 +12,7 @@ describe("isLocalTauriHost", () => { assert.equal(isLocalTauriHost(environment("tauri", "local")), true) }) - it("keeps native-only features disabled in remote Tauri windows", () => { + it("keeps native-only features disabled in non-local Tauri contexts", () => { assert.equal(isLocalTauriHost(environment("tauri", "remote")), false) }) @@ -43,7 +43,6 @@ describe("Preferences native capabilities", () => { }) try { assert.equal(canUseNativeDialogs(), true) - assert.equal(canOpenRemoteWindows(), true) assert.equal(canRestartCli(), true) } finally { Object.assign(globalThis, { window: previousWindow }) diff --git a/packages/ui/src/lib/runtime-env.ts b/packages/ui/src/lib/runtime-env.ts index 45935338b..5e773251c 100644 --- a/packages/ui/src/lib/runtime-env.ts +++ b/packages/ui/src/lib/runtime-env.ts @@ -127,13 +127,11 @@ export const isLocalTauriHost = ( export const isDesktopHost = () => isElectronHost() || isTauriHost() export const isMobilePlatform = () => detectPlatform() === "mobile" export const isLocalWindow = () => detectWindowContext() === "local" -export const isRemoteWindow = () => detectWindowContext() === "remote" export const isPreferencesWindow = () => detectWindowContext() === "preferences" export const usesClientState = ( environment: Pick = detectRuntimeEnvironment(), ) => environment.windowContext !== "preferences" export const isNativeApplicationWindow = () => isLocalWindow() || isPreferencesWindow() export const canUseNativeDialogs = () => isDesktopHost() && isNativeApplicationWindow() -export const canOpenRemoteWindows = () => isDesktopHost() && isNativeApplicationWindow() export const canRestartCli = () => isDesktopHost() && isNativeApplicationWindow() export const canUseDesktopFolderDrop = () => isDesktopHost() && isLocalWindow() diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 7aadfdce4..5d88f4966 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,7 +1,6 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" import { storage, type OwnerBucket } from "../lib/storage" -import type { RemoteServerProfile } from "../../../server/src/api-types" import { ensureInstanceConfigLoaded, getInstanceConfig, @@ -51,7 +50,6 @@ export type VisibilityPreference = "hidden" | ExpansionPreference export type ToolCallExpansionPreset = "minimal" | "balanced" | "detailed" | "everything" export type ToolCallExpansionPresetSelection = ToolCallExpansionPreset | "custom" export type ToolInputsVisibilityPreference = VisibilityPreference -export type ListeningMode = "local" | "all" export type ServerLogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" export type SpeechProviderPreference = "openai-compatible" export type SpeechPlaybackMode = "streaming" | "buffered" @@ -156,7 +154,6 @@ interface UiConfigBucket { } interface ServerConfigBucket { - listeningMode?: ListeningMode logLevel?: ServerLogLevel environmentVariables?: Record secureEnvVars?: string[] @@ -172,7 +169,6 @@ interface UiStateBucket { activeColorSchemePresetId?: string recentFolders?: RecentFolder[] opencodeBinaries?: OpenCodeBinary[] - remoteServers?: RemoteServerProfile[] models?: { recents?: ModelPreference[] favorites?: ModelPreference[] @@ -183,7 +179,6 @@ interface UiStateBucket { interface NormalizedUiState { recentFolders: RecentFolder[] opencodeBinaries: OpenCodeBinary[] - remoteServers: RemoteServerProfile[] models: { recents: ModelPreference[] favorites: ModelPreference[] @@ -440,29 +435,6 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { const label = typeof (b as any).label === "string" ? (b as any).label : undefined return { path: p, version, label, lastUsed } }), - remoteServers: cloneArray(source.remoteServers, (server) => { - if (!server || typeof server !== "object") return null - const id = typeof (server as any).id === "string" ? (server as any).id.trim() : "" - const name = typeof (server as any).name === "string" ? (server as any).name.trim() : "" - const baseUrl = typeof (server as any).baseUrl === "string" ? (server as any).baseUrl.trim() : "" - if (!id || !name || !baseUrl) return null - const createdAt = typeof (server as any).createdAt === "string" ? (server as any).createdAt : new Date().toISOString() - const updatedAt = typeof (server as any).updatedAt === "string" ? (server as any).updatedAt : createdAt - const lastConnectedAt = typeof (server as any).lastConnectedAt === "string" ? (server as any).lastConnectedAt : undefined - return { - id, - name, - baseUrl, - skipTlsVerify: Boolean((server as any).skipTlsVerify), - createdAt, - updatedAt, - lastConnectedAt, - } - }).sort((a, b) => { - const left = a.lastConnectedAt ?? a.updatedAt - const right = b.lastConnectedAt ?? b.updatedAt - return right.localeCompare(left) - }), models: { recents: cloneArray((source.models as any)?.recents, (m) => { if (!m || typeof m !== "object") return null @@ -485,9 +457,8 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { export function normalizeServerConfig( input?: ServerConfigBucket | null, -): Required> & { speech: SpeechSettings } { +): Required> & { speech: SpeechSettings } { const source = input ?? {} - const listeningMode = source.listeningMode === "all" ? "all" : "local" const logLevel = source.logLevel === "INFO" || source.logLevel === "WARN" || source.logLevel === "ERROR" || source.logLevel === "DEBUG" ? source.logLevel @@ -498,7 +469,7 @@ export function normalizeServerConfig( const environmentVariables = normalizeRecord(source.environmentVariables) const secureEnvVars = normalizeSecureEnvVars(source.secureEnvVars) const speech = normalizeSpeechSettings(source.speech) - return { listeningMode, logLevel, opencodeBinary, environmentVariables, secureEnvVars, speech } + return { logLevel, opencodeBinary, environmentVariables, secureEnvVars, speech } } function normalizeSecureEnvVars(input?: unknown): string[] { @@ -539,43 +510,6 @@ export function buildBinaryList(binaryPath: string, version: string | undefined, return [nextEntry, ...source].slice(0, 10) } -interface RemoteServerProfileInput { - id?: string - name: string - baseUrl: string - skipTlsVerify: boolean -} - -function buildRemoteServerProfile(input: RemoteServerProfileInput, source: RemoteServerProfile[]): RemoteServerProfile { - const existing = input.id ? source.find((entry) => entry.id === input.id) : undefined - const now = new Date().toISOString() - return { - id: existing?.id ?? input.id ?? createRandomId(), - name: input.name.trim(), - baseUrl: input.baseUrl.trim(), - skipTlsVerify: Boolean(input.skipTlsVerify), - createdAt: existing?.createdAt ?? now, - updatedAt: now, - lastConnectedAt: existing?.lastConnectedAt, - } -} - -function buildRemoteServerList(profile: RemoteServerProfile, source: RemoteServerProfile[]): RemoteServerProfile[] { - const remaining = source.filter((entry) => entry.id !== profile.id) - return [profile, ...remaining].sort((a, b) => { - const left = a.lastConnectedAt ?? a.updatedAt - const right = b.lastConnectedAt ?? b.updatedAt - return right.localeCompare(left) - }) -} - -function createRandomId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID() - } - return `remote-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` -} - const [uiConfigBucket, setUiConfigBucket] = createSignal({}) const [serverConfigBucket, setServerConfigBucket] = createSignal({}) const [uiStateBucket, setUiStateBucket] = createSignal({}) @@ -606,7 +540,6 @@ const uiState = createMemo(() => normalizeUiState(uiStateBucket())) const preferences = uiSettings const recentFolders = createMemo(() => uiState().recentFolders) const opencodeBinaries = createMemo(() => uiState().opencodeBinaries) -const remoteServers = createMemo(() => uiState().remoteServers) let loadPromise: Promise | null = null @@ -787,11 +720,6 @@ function saveColorSchemePreset(name: string, appearance: "light" | "dark", color return write.then(() => id) } - async function setListeningMode(mode: ListeningMode): Promise { - if (serverSettings().listeningMode === mode) return - await patchConfigOwner("server", { listeningMode: mode }) - } - function updateEnvironmentVariables(envVars: Record): void { void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) => log.error("Failed to update environment variables", error), @@ -908,29 +836,6 @@ async function renameRecentFolderProject(folderPath: string, projectName: string } } -async function saveRemoteServerProfile(input: RemoteServerProfileInput): Promise { - const profile = buildRemoteServerProfile(input, remoteServers()) - await patchStateOwner("ui", { remoteServers: buildRemoteServerList(profile, remoteServers()) }) - return profile -} - -async function markRemoteServerConnected(id: string): Promise { - const current = remoteServers().find((entry) => entry.id === id) - if (!current) return - const now = new Date().toISOString() - const updated: RemoteServerProfile = { - ...current, - updatedAt: now, - lastConnectedAt: now, - } - await patchStateOwner("ui", { remoteServers: buildRemoteServerList(updated, remoteServers()) }) -} - -function removeRemoteServerProfile(id: string): void { - const next = remoteServers().filter((entry) => entry.id !== id) - void patchStateOwner("ui", { remoteServers: next }).catch((error) => log.error("Failed to remove remote server", error)) -} - function recordWorkspaceLaunch(folderPath: string, aliasPath?: string): void { const nextFolders = buildRecentFolderList(folderPath, recentFolders(), aliasPath) @@ -1111,7 +1016,6 @@ interface ConfigContextValue { // server-owned stable config serverSettings: typeof serverSettings - setListeningMode: typeof setListeningMode updateEnvironmentVariables: typeof updateEnvironmentVariables addEnvironmentVariable: typeof addEnvironmentVariable removeEnvironmentVariable: typeof removeEnvironmentVariable @@ -1124,16 +1028,12 @@ interface ConfigContextValue { // ui-owned state recentFolders: typeof recentFolders opencodeBinaries: typeof opencodeBinaries - remoteServers: typeof remoteServers uiState: typeof uiState addRecentFolder: typeof addRecentFolder removeRecentFolder: typeof removeRecentFolder renameRecentFolderProject: typeof renameRecentFolderProject addOpenCodeBinary: typeof addOpenCodeBinary removeOpenCodeBinary: typeof removeOpenCodeBinary - saveRemoteServerProfile: typeof saveRemoteServerProfile - markRemoteServerConnected: typeof markRemoteServerConnected - removeRemoteServerProfile: typeof removeRemoteServerProfile recordWorkspaceLaunch: typeof recordWorkspaceLaunch addRecentModelPreference: typeof addRecentModelPreference isFavoriteModelPreference: typeof isFavoriteModelPreference @@ -1180,7 +1080,6 @@ const configContextValue: ConfigContextValue = { selectColorSchemePreset, saveColorSchemePreset, serverSettings, - setListeningMode, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, @@ -1191,16 +1090,12 @@ const configContextValue: ConfigContextValue = { updateSpeechSettings, recentFolders, opencodeBinaries, - remoteServers, uiState, addRecentFolder, removeRecentFolder, renameRecentFolderProject, addOpenCodeBinary, removeOpenCodeBinary, - saveRemoteServerProfile, - markRemoteServerConnected, - removeRemoteServerProfile, recordWorkspaceLaunch, addRecentModelPreference, isFavoriteModelPreference, @@ -1282,7 +1177,6 @@ export { setProviderModelVisibility, getProviderModelVisibilityPreference, providerModelVisibilitySaveFailed, - setListeningMode, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, diff --git a/packages/ui/src/styles/components/remote-access.css b/packages/ui/src/styles/components/remote-access.css deleted file mode 100644 index 3e5fa4eb2..000000000 --- a/packages/ui/src/styles/components/remote-access.css +++ /dev/null @@ -1,346 +0,0 @@ -.remote-overlay { - position: fixed; - inset: 0; - z-index: 41; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.modal-overlay.remote-overlay-backdrop { - background: var(--overlay-scrim); - backdrop-filter: blur(6px); - z-index: 40; -} - -.remote-panel { - width: min(960px, 100%); - max-height: 90vh; - overflow: hidden; - display: flex; - flex-direction: column; -} - -.remote-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - padding: 20px 24px; - border-bottom: 1px solid var(--border-base); -} - -.remote-eyebrow { - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 11px; - color: var(--text-subtle); - margin: 0 0 4px; -} - -.remote-title { - margin: 0; - font-size: 20px; - color: var(--text-primary); -} - -.remote-subtitle { - margin: 4px 0 0; - color: var(--text-secondary); - font-size: 14px; -} - -.remote-close { - border: 1px solid var(--border-base); - background: var(--surface-secondary); - color: var(--text-primary); - border-radius: 999px; - padding: 6px 10px; - cursor: pointer; - font-size: 18px; - line-height: 1; -} - -.remote-body { - padding: 16px 24px 24px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 16px; -} - -.remote-section { - border: 1px solid var(--border-base); - border-radius: 12px; - background: var(--surface-secondary); - padding: 16px; -} - -.remote-section-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.remote-section-title { - display: flex; - gap: 10px; - align-items: center; -} - -.remote-icon { - width: 18px; - height: 18px; -} - -.remote-label { - margin: 0; - color: var(--text-primary); - font-weight: 600; -} - -.remote-help { - margin: 2px 0 0; - color: var(--text-secondary); - font-size: 13px; -} - -.remote-refresh { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 10px; - border-radius: 10px; - border: 1px solid var(--border-base); - background: var(--surface-primary); - color: var(--text-primary); - cursor: pointer; -} - -.remote-refresh-label { - display: inline-block; -} - -@media (max-width: 640px) { - .remote-refresh-label { - display: none; - } -} - -.remote-toggle { - position: relative; - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - border-radius: 12px; - border: 1px solid var(--border-base); - background: var(--surface-primary); - cursor: pointer; -} - -.remote-toggle-switch { - width: 58px; - height: 28px; - border-radius: 999px; - background: var(--surface-secondary); - border: 1px solid var(--border-base); - display: inline-flex; - align-items: center; - justify-content: space-between; - padding: 0 8px 0 6px; - transition: background 0.2s ease, border-color 0.2s ease; - font-size: 11px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.remote-toggle-state { - pointer-events: none; - white-space: nowrap; -} - -.remote-toggle-thumb { - width: 18px; - height: 18px; - border-radius: 999px; - background: var(--surface-primary); - transition: transform 0.2s ease; - transform: translateX(0); -} - -.remote-toggle-switch[data-checked="true"] { - background: var(--accent-primary); - border-color: var(--accent-primary); - color: var(--surface-primary); -} - -.remote-toggle-switch[data-checked="true"] .remote-toggle-thumb { - transform: translateX(20px); -} - -.remote-toggle-copy { - display: flex; - flex-direction: column; - gap: 2px; -} - -.remote-toggle-title { - font-weight: 600; - color: var(--text-primary); -} - -.remote-toggle-caption { - font-size: 13px; - color: var(--text-secondary); -} - -.remote-toggle-note { - margin: 12px 0 0; - font-size: 13px; - color: var(--text-secondary); -} - -.remote-address-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.remote-address { - border: 1px solid var(--border-base); - border-radius: 12px; - padding: 12px; - background: var(--surface-primary); -} - -.remote-address-main { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; -} - -.remote-address-url { - margin: 0; - font-weight: 600; - color: var(--text-primary); -} - -.remote-address-meta { - margin: 4px 0 0; - color: var(--text-secondary); - font-size: 12px; -} - -.remote-actions { - display: flex; - gap: 8px; -} - -.remote-pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 10px; - border-radius: 999px; - border: 1px solid var(--border-base); - background: var(--surface-secondary); - color: var(--text-primary); - cursor: pointer; -} - -.remote-address-disclosure { - border: 1px solid var(--border-base); - border-radius: 12px; - background: var(--surface-primary); - overflow: hidden; -} - -.remote-address-disclosure-trigger { - width: 100%; - min-height: 40px; - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - padding: 8px 12px; - border: 0; - background: transparent; - color: var(--text-primary); - cursor: pointer; -} - -.remote-address-disclosure-label { - grid-column: 2; - justify-self: center; - text-align: center; - font-size: 13px; - font-weight: 600; -} - -.remote-address-disclosure-chevron { - grid-column: 3; - justify-self: end; - width: 16px; - height: 16px; - color: var(--text-secondary); -} - -.remote-address-disclosure-content { - display: flex; - flex-direction: column; - gap: 10px; - padding: 0 10px 10px; - border-top: 1px solid var(--border-base); -} - -.remote-qr { - margin-top: 12px; - display: flex; - align-items: center; - justify-content: center; - padding: 12px; - border: 1px dashed var(--border-base); - border-radius: 10px; - background: var(--surface-secondary); -} - -.remote-qr-img { - width: 160px; - height: 160px; - image-rendering: pixelated; -} - -.remote-card { - border: 1px dashed var(--border-base); - border-radius: 10px; - padding: 12px; - color: var(--text-secondary); -} - -.remote-error { - border: 1px solid var(--border-critical, #e65c5c); - background: color-mix(in srgb, var(--border-critical, #e65c5c) 10%, transparent); - border-radius: 10px; - padding: 12px; - color: var(--text-primary); -} - -.remote-spin { - animation: remote-spin 1s linear infinite; -} - -@keyframes remote-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/packages/ui/src/styles/components/remote-control.css b/packages/ui/src/styles/components/remote-control.css new file mode 100644 index 000000000..bec7ff0df --- /dev/null +++ b/packages/ui/src/styles/components/remote-control.css @@ -0,0 +1,104 @@ +.remote-control-status { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid var(--border-base); + background: var(--surface-secondary); +} + +.remote-control-status strong, +.remote-control-device strong { + color: var(--text-primary); +} + +.remote-control-status p, +.remote-control-device p { + margin: 2px 0 0; + color: var(--text-secondary); + font-size: 12px; +} + +.remote-control-status-dot { + width: 10px; + height: 10px; + flex: 0 0 auto; + background: var(--text-subtle); +} + +.remote-control-status[data-state="connected"] .remote-control-status-dot { + background: var(--status-success, #3fb950); +} + +.remote-control-status[data-state="connecting"] .remote-control-status-dot, +.remote-control-status[data-state="reconnecting"] .remote-control-status-dot { + background: var(--status-warning, #d29922); +} + +.remote-control-status[data-state="error"] .remote-control-status-dot { + background: var(--status-danger, #f85149); +} + +.remote-control-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.remote-control-pairing { + align-items: flex-start; +} + +.remote-control-qr, +.remote-control-qr-loading { + width: 180px; + height: 180px; + align-self: center; + border: 1px solid var(--border-base); + background: #fff; + image-rendering: pixelated; +} + +.remote-control-qr-loading { + padding: 72px; + color: var(--text-secondary); + background: var(--surface-secondary); +} + +.remote-control-link { + display: block; + width: 100%; + padding: 10px; + overflow-wrap: anywhere; + border: 1px solid var(--border-base); + background: var(--surface-primary); + color: var(--text-secondary); + font-size: 11px; +} + +.remote-control-devices { + display: flex; + flex-direction: column; + border: 1px solid var(--border-base); +} + +.remote-control-device { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-bottom: 1px solid var(--border-base); +} + +.remote-control-device:last-child { + border-bottom: 0; +} + +.remote-spin { + animation: remote-spin 1s linear infinite; +} + +@keyframes remote-spin { + to { transform: rotate(360deg); } +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 32ba49ad9..ed063fc52 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -9,7 +9,7 @@ @import "./components/selector.css"; @import "./components/env-vars.css"; @import "./components/directory-browser.css"; -@import "./components/remote-access.css"; +@import "./components/remote-control.css"; @import "./components/permission-notification.css"; @import "./components/form-request.css"; @import "./components/form-request-options.css"; diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 9cda5d459..6ddd8b9a2 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -73,14 +73,6 @@ declare global { clearClientState?: (accessToken: string) => Promise showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }> - openRemoteWindow?: (payload: { - id: string - name: string - baseUrl: string - entryUrl?: string - proxySessionId?: string - skipTlsVerify: boolean - }) => Promise<{ ok: boolean }> openPreferences?: (section: SettingsSectionId, context?: { instanceId?: string; location?: LocationRef }) => Promise getPreferencesRequest?: () => Promise getPreferencesSection?: () => Promise diff --git a/scripts/desktop-server-resources.cjs b/scripts/desktop-server-resources.cjs index 20684fdf0..b49867b80 100644 --- a/scripts/desktop-server-resources.cjs +++ b/scripts/desktop-server-resources.cjs @@ -49,7 +49,11 @@ function validateServerProductionLock(lock) { visited.add(packagePath) const pkg = packages[packagePath] if (!pkg) throw new Error(`Root package-lock.json is missing ${packagePath}`) - if (packagePath !== "packages/server" && (!pkg.version || !pkg.resolved || !pkg.integrity)) { + if (pkg.link && typeof pkg.resolved === "string") { + pending.push(pkg.resolved) + continue + } + if (!packagePath.startsWith("packages/") && (!pkg.version || !pkg.resolved || !pkg.integrity)) { throw new Error(`Root package-lock.json does not integrity-pin ${packagePath}`) } const dependencies = { ...pkg.dependencies, ...pkg.optionalDependencies } @@ -66,7 +70,7 @@ function stagePackagedServer(options) { const { workspaceRoot, serverRoot, log = () => {}, env = process.env } = options const npmTarget = resolveNpmTarget(options.target || env.CODENOMAD_NODE_TARGET) const lockPath = path.join(workspaceRoot, "package-lock.json") - validateServerProductionLock(JSON.parse(fs.readFileSync(lockPath, "utf8"))) + const productionClosure = validateServerProductionLock(JSON.parse(fs.readFileSync(lockPath, "utf8"))) const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-server-")) const stagedServerRoot = path.join(stagingRoot, "packages", "server") @@ -75,6 +79,16 @@ function stagePackagedServer(options) { fs.copyFileSync(path.join(workspaceRoot, "package.json"), path.join(stagingRoot, "package.json")) fs.copyFileSync(lockPath, path.join(stagingRoot, "package-lock.json")) fs.copyFileSync(path.join(serverRoot, "package.json"), path.join(stagedServerRoot, "package.json")) + for (const packagePath of productionClosure) { + if (!packagePath.startsWith("packages/") || packagePath === "packages/server") continue + const source = path.join(workspaceRoot, packagePath) + const destination = path.join(stagingRoot, packagePath) + fs.mkdirSync(destination, { recursive: true }) + fs.copyFileSync(path.join(source, "package.json"), path.join(destination, "package.json")) + if (fs.existsSync(path.join(source, "dist"))) { + fs.cpSync(path.join(source, "dist"), path.join(destination, "dist"), { recursive: true }) + } + } log(`installing production server dependencies from the workspace lock for ${npmTarget.target}`) const npmArgs = [ diff --git a/scripts/desktop-server-resources.test.cjs b/scripts/desktop-server-resources.test.cjs index dcd527c56..c26d436e2 100644 --- a/scripts/desktop-server-resources.test.cjs +++ b/scripts/desktop-server-resources.test.cjs @@ -26,6 +26,7 @@ test("integrity-pins the full server production closure in the root lock", () => assert.equal(lock.packages["node_modules/undici"].version, "6.22.0") assert.equal(lock.packages["packages/server/node_modules/commander"].version, "12.1.0") assert.equal(lock.packages["packages/server/node_modules/fuzzysort"].version, "2.0.4") + assert.ok(closure.has("packages/remote-control-protocol")) }) test("resolves a macOS ARM64 esbuild binary nested under esbuild", (t) => { From 2fd73c6ebd915525cd11b4c60152dd2fccd2fab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 4 Sep 2026 03:09:33 +0200 Subject: [PATCH 2/9] feat(remote-control): harden encrypted relay transport Upgrade Remote Control to an authenticated protocol v2 with persistent host P-256 identity, ephemeral browser keys, fresh host challenges, directional AES-GCM channels, and replay-resistant counters. Keep Cloudflare opaque to application payloads while preserving one-time pairing and revocable device credentials. Move host and client sockets onto Durable Object WebSocket Hibernation, add bounded pairings, devices, clients, HTTP work, WebSockets, frames, handshakes, and queues, and constrain decrypted loopback traffic to CodeNomad API and workspace namespaces. Filter remote credentials and forwarding metadata before injecting the host-local session. Install the encrypted browser transport before application startup, including multiplexed streaming fetch, reconnecting SSE with Last-Event-ID, and same-origin WebSocket bridging. Keep hashed UI assets public so asset delivery does not wake an idle Durable Object while authenticating HTML and bootstrap discovery. Add protocol tamper/replay coverage, Worker authorization tests, Wrangler end-to-end pairing/HTTP/WebSocket/reconnect/revocation/limit coverage, browser transport tests, CI integration, deployment documentation, and trust-boundary guidance. --- .github/workflows/pr-build.yml | 5 +- dev-docs/architecture.md | 4 +- package-lock.json | 1 + packages/cloudflare/README.md | 76 ++ packages/cloudflare/package-lock.json | 691 +++++++++--------- packages/cloudflare/package.json | 8 +- .../cloudflare/scripts/prepare-e2e-assets.mjs | 8 + packages/cloudflare/scripts/release-ui.mjs | 9 + .../scripts/remote-control-e2e.test.ts | 406 ++++++++++ packages/cloudflare/src/index.test.ts | 114 ++- packages/cloudflare/src/index.ts | 109 ++- .../cloudflare/src/remote-control/headers.ts | 51 -- .../src/remote-control/host-object.ts | 560 +++++++------- .../src/remote-control/relay-messages.ts | 56 ++ .../src/remote-control/security.test.ts | 1 + .../cloudflare/src/remote-control/security.ts | 10 +- packages/cloudflare/wrangler.toml | 1 + .../remote-control-protocol/src/crypto.ts | 249 +++++++ .../remote-control-protocol/src/index.test.ts | 60 +- packages/remote-control-protocol/src/index.ts | 89 +-- .../remote-control-protocol/src/messages.ts | 99 +++ packages/server/README.md | 6 +- .../src/remote-control/connector-protocol.ts | 120 +++ .../src/remote-control/connector.test.ts | 26 + .../server/src/remote-control/connector.ts | 362 ++++++--- .../src/remote-control/identity.test.ts | 43 ++ .../server/src/remote-control/identity.ts | 54 +- packages/server/src/remote-control/manager.ts | 14 +- packages/ui/package.json | 2 + packages/ui/src/bootstrap.ts | 6 + .../lib/remote-control/event-source.test.ts | 67 ++ .../ui/src/lib/remote-control/event-source.ts | 120 +++ packages/ui/src/lib/remote-control/tunnel.ts | 413 +++++++++++ .../src/lib/remote-control/web-socket.test.ts | 58 ++ .../ui/src/lib/remote-control/web-socket.ts | 133 ++++ packages/ui/src/renderer/index.html | 2 +- packages/ui/src/types/global.d.ts | 1 + 37 files changed, 3145 insertions(+), 889 deletions(-) create mode 100644 packages/cloudflare/README.md create mode 100644 packages/cloudflare/scripts/prepare-e2e-assets.mjs create mode 100644 packages/cloudflare/scripts/remote-control-e2e.test.ts delete mode 100644 packages/cloudflare/src/remote-control/headers.ts create mode 100644 packages/cloudflare/src/remote-control/relay-messages.ts create mode 100644 packages/remote-control-protocol/src/crypto.ts create mode 100644 packages/remote-control-protocol/src/messages.ts create mode 100644 packages/server/src/remote-control/connector-protocol.ts create mode 100644 packages/ui/src/bootstrap.ts create mode 100644 packages/ui/src/lib/remote-control/event-source.test.ts create mode 100644 packages/ui/src/lib/remote-control/event-source.ts create mode 100644 packages/ui/src/lib/remote-control/tunnel.ts create mode 100644 packages/ui/src/lib/remote-control/web-socket.test.ts create mode 100644 packages/ui/src/lib/remote-control/web-socket.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 5a5a65d9a..e09be4233 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -109,7 +109,8 @@ jobs: - name: Test Remote Control protocol and relay run: >- npm test --workspace @codenomad/remote-control-protocol && - npm test --prefix packages/cloudflare + npm test --prefix packages/cloudflare && + npm run test:e2e --prefix packages/cloudflare - name: Test desktop packaging invariants run: node --test scripts/desktop-server-resources.test.cjs @@ -140,6 +141,8 @@ jobs: packages/ui/src/lib/message-selection-position.test.ts packages/ui/src/lib/model-visibility.test.ts packages/ui/src/lib/runtime-env.test.ts + packages/ui/src/lib/remote-control/event-source.test.ts + packages/ui/src/lib/remote-control/web-socket.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index fff3d55da..381119232 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -42,9 +42,9 @@ Previews use unguessable capabilities for HTTP and WebSocket traffic. Native pre CodeNomad listens only on `127.0.0.1`. Remote Control is an outbound-only connection from `packages/server/src/remote-control/` to the Cloudflare Worker and one `RemoteControlHost` Durable Object per random host ID. OpenCode is never exposed directly; relayed requests terminate at CodeNomad and continue through its existing authentication, folder, Git, Yolo, and proxy boundaries. -The persistent host identity is stored in `remote-control.json` with restricted permissions where supported. The connector authenticates with a bearer secret, while browsers pair through a fragment-token link that expires after ten minutes. The relay stores only token hashes, issues secure host-scoped device cookies for 30 days, and supports revocation. Remote credentials are stripped before forwarding; the local connector injects a dedicated internal CodeNomad session instead. +The persistent host identity and P-256 key pair are stored in `remote-control.json` with restricted permissions where supported; legacy identities gain a key pair without changing their host ID or relay secret. The connector authenticates with a bearer secret, while browsers pair through a fragment-token link that expires after ten minutes and pins the host public key. The relay stores only token hashes, issues secure host-scoped device cookies for 30 days, and supports revocation. Remote credentials are stripped only after host-side decryption; the local connector injects a dedicated internal CodeNomad session instead. -HTTP bodies and WebSocket frames share the versioned types in `packages/remote-control-protocol/`. The relay streams HTTP responses, propagates WebSocket subprotocols, bounds pre-handshake queues, cancels abandoned work, and rejects stale responses after a host reconnect. Electron and Tauri keep the backend alive after the final window closes only while Remote Control is enabled. +Protocol v2 carries HTTP streams and WebSocket messages inside an end-to-end encrypted browser-to-host tunnel. An ephemeral browser P-256 key, a fresh host challenge, ECDH, and HKDF-SHA-256 produce directional AES-256-GCM keys; authenticated counters reject tampering, reordering, and replay across the same or later tunnels. Cloudflare sees host/device routing, sizes, and timing, but receives neither application plaintext nor the host private key. This protects against an honest-but-curious relay and captured tunnel traffic, not an actively malicious Worker operator that replaces the browser bundle before it runs; reviewed releases and Cloudflare account security remain in the trust boundary. Host and remote-client sockets use the Durable Objects WebSocket Hibernation API, with attachment metadata sufficient to recover routing after an object is evicted. Connector heartbeats use Cloudflare's automatic WebSocket response path so idle hosts stay reachable without waking the object. The relay and connector bound clients, requests, sockets, devices, pairing links, bodies, frames, and queues; stream HTTP responses with idle timeouts; cancel abandoned work; and reject stale responses after a host reconnect. Electron and Tauri keep the backend alive after the final window closes only while Remote Control is enabled. ## API Boundaries diff --git a/package-lock.json b/package-lock.json index 7810d6688..b836928c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15032,6 +15032,7 @@ "version": "0.20.0-dev-v2", "license": "MIT", "dependencies": { + "@codenomad/remote-control-protocol": "0.1.0", "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", "@opencode-ai/client": "beta", diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md new file mode 100644 index 000000000..b6f57c540 --- /dev/null +++ b/packages/cloudflare/README.md @@ -0,0 +1,76 @@ +# CodeNomad Cloudflare Worker + +This package serves the published web UI and the shared Remote Control relay. + +## Remote Control topology + +- The CodeNomad host opens one authenticated outbound WebSocket. +- A random 128-bit host ID selects one `RemoteControlHost` Durable Object. +- Remote browsers use a one-time pairing link and a host-scoped device cookie. +- The pairing fragment pins the host's persistent P-256 public key without + disclosing the one-time token to the relay in an HTTP request URL. +- The Worker authenticates and routes devices, but forwards application traffic + only as opaque encrypted tunnel frames. +- The local connector strips remote headers and injects its private CodeNomad + session only after decrypting a request on the host. +- OpenCode remains behind the loopback-only CodeNomad server. + +The browser uses an ephemeral P-256 key and the host contributes a fresh +challenge to every tunnel. HKDF-SHA-256 derives separate client-to-host and +host-to-client AES-256-GCM keys. Authenticated counters reject tampering, +reordering, same-tunnel replay, and replay into a later tunnel. The private host +key and plaintext never enter Worker storage or messages. The relay can still +observe routing metadata, frame sizes, and timing, and it serves the published +browser bundle. E2EE protects against an honest-but-curious relay, storage +disclosure, and captured tunnel traffic. It cannot protect a session from an +actively malicious Worker operator that replaces the browser JavaScript before +it runs; release review and Cloudflare account security remain part of the +trust boundary. + +Both host and browser WebSockets use the Durable Objects WebSocket Hibernation +API. Socket attachments contain the minimum routing metadata needed after an +object is evicted. The host heartbeat is handled with a WebSocket auto response, +so it does not wake an idle object. Hashed UI assets are public and immutable; +only HTML navigation, bootstrap discovery, pairing, management, and tunnel +operations consult the host object. + +## Resource limits + +Each host object limits browser tunnels, paired devices, unredeemed pairing +links, pairing bodies, and opaque frame sizes. The local connector separately +limits concurrent HTTP requests, local WebSockets, decrypted request bodies, +handshake frames, and pre-open socket queues. HTTP streams have an idle timeout +and abandoned requests are cancelled at the host. Expired pairing and device +records are removed by a Durable Object alarm. + +These limits contain abuse and memory use, but they are not a substitute for +Cloudflare account-level usage alerts and limits. + +## Validation + +```sh +npm install +npm run typecheck +npm test +npm run test:e2e +npx wrangler deploy --dry-run +``` + +The end-to-end test starts a local Wrangler relay plus a loopback host and +verifies one-time pairing, encrypted multiplexed HTTP streaming, header +isolation, connector recovery, pairing and client limits, WebSocket forwarding, +and live revocation. Use `npm run dev` for manual testing with a local CodeNomad +server. The protocol package is built automatically before either flow. + +## Deployment + +`wrangler.toml` requires: + +- the `REMOTE_HOSTS` Durable Object binding; +- an assets directory built into `dist`; +- the `ui.codenomad.neuralnomads.ai` and + `remote.codenomad.neuralnomads.ai` custom domains; +- a wildcard route for `*.remote.codenomad.neuralnomads.ai`. + +Deploy only from the Cloudflare account that owns the `neuralnomads.ai` zone. +Do not put host or device credentials in URLs, logs, or Worker variables. diff --git a/packages/cloudflare/package-lock.json b/packages/cloudflare/package-lock.json index 699f6b168..5561a4adf 100644 --- a/packages/cloudflare/package-lock.json +++ b/packages/cloudflare/package-lock.json @@ -10,10 +10,11 @@ "@codenomad/remote-control-protocol": "file:../remote-control-protocol" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260702.1", + "@cloudflare/workers-types": "^5.20260903.1", "tsx": "^4.20.6", "typescript": "^5.6.3", - "wrangler": "^4.0.0" + "undici": "^6.19.8", + "wrangler": "^4.129.0" } }, "../remote-control-protocol": { @@ -26,24 +27,24 @@ } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.11.0.tgz", - "integrity": "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { "unenv": "2.0.0-rc.24", - "workerd": "^1.20260115.0" + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -52,9 +53,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260120.0.tgz", - "integrity": "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260903.1.tgz", + "integrity": "sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==", "cpu": [ "x64" ], @@ -69,9 +70,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260120.0.tgz", - "integrity": "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260903.1.tgz", + "integrity": "sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==", "cpu": [ "arm64" ], @@ -86,9 +87,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260120.0.tgz", - "integrity": "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260903.1.tgz", + "integrity": "sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==", "cpu": [ "x64" ], @@ -103,9 +104,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260120.0.tgz", - "integrity": "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260903.1.tgz", + "integrity": "sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==", "cpu": [ "arm64" ], @@ -120,9 +121,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260120.0.tgz", - "integrity": "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260903.1.tgz", + "integrity": "sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==", "cpu": [ "x64" ], @@ -137,9 +138,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260702.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", - "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "version": "5.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260903.1.tgz", + "integrity": "sha512-Dgm28XJqMYj3VCNK14Xwo9UPtSPWL8Izo0emiyeQLZCRXWuhc3GnlKENF4Pf3ot+3NaWosq+yq8OIa531dCr6g==", "dev": true, "license": "MIT OR Apache-2.0", "peer": true @@ -162,9 +163,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -173,9 +174,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -190,9 +191,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -207,9 +208,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -224,9 +225,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -241,9 +242,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -258,9 +259,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -275,9 +276,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -292,9 +293,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -309,9 +310,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -326,9 +327,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -343,9 +344,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -360,9 +361,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -377,9 +378,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -394,9 +395,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -411,9 +412,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -428,9 +429,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -445,9 +446,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -462,9 +463,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -479,9 +480,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -496,9 +497,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -513,9 +514,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -530,9 +531,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -547,9 +548,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -564,9 +565,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -581,9 +582,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -598,9 +599,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -615,9 +616,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", "engines": { @@ -625,9 +626,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", "cpu": [ "arm64" ], @@ -638,19 +639,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.1" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", "cpu": [ "x64" ], @@ -661,19 +662,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", "cpu": [ "arm64" ], @@ -688,9 +709,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", "cpu": [ "x64" ], @@ -705,9 +726,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", "cpu": [ "arm" ], @@ -722,9 +743,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", "cpu": [ "arm64" ], @@ -739,9 +760,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", "cpu": [ "ppc64" ], @@ -756,9 +777,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", "cpu": [ "riscv64" ], @@ -773,9 +794,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", "cpu": [ "s390x" ], @@ -790,9 +811,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", "cpu": [ "x64" ], @@ -807,9 +828,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", "cpu": [ "arm64" ], @@ -824,9 +845,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", "cpu": [ "x64" ], @@ -841,9 +862,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", "cpu": [ "arm" ], @@ -854,19 +875,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.1" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", "cpu": [ "arm64" ], @@ -877,19 +898,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.1" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", "cpu": [ "ppc64" ], @@ -900,19 +921,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.1" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", "cpu": [ "riscv64" ], @@ -923,19 +944,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.1" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", "cpu": [ "s390x" ], @@ -946,19 +967,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.1" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", "cpu": [ "x64" ], @@ -969,19 +990,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", "cpu": [ "arm64" ], @@ -992,19 +1013,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", "cpu": [ "x64" ], @@ -1015,39 +1036,56 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", "cpu": [ "wasm32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.2" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", "cpu": [ "arm64" ], @@ -1058,16 +1096,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", "cpu": [ "ia32" ], @@ -1078,16 +1116,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", "cpu": [ "x64" ], @@ -1098,7 +1136,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1115,9 +1153,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1175,9 +1213,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", - "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", "dev": true, "license": "CC0-1.0" }, @@ -1223,9 +1261,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1236,32 +1274,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/fsevents": { @@ -1290,25 +1328,31 @@ } }, "node_modules/miniflare": { - "version": "4.20260120.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260120.0.tgz", - "integrity": "sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q==", + "version": "5.20260903.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260903.0-alpha.tgz", + "integrity": "sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.18.2", - "workerd": "1.20260120.0", - "ws": "8.18.0", - "youch": "4.1.0-beta.10", - "zod": "^3.25.76" - }, - "bin": { - "miniflare": "bootstrap.js" + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260903.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" } }, "node_modules/path-to-regexp": { @@ -1326,9 +1370,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -1339,48 +1383,48 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" } }, "node_modules/supports-color": { @@ -1922,13 +1966,13 @@ } }, "node_modules/undici": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", - "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=18.17" } }, "node_modules/unenv": { @@ -1943,9 +1987,9 @@ } }, "node_modules/workerd": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260120.0.tgz", - "integrity": "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260903.1.tgz", + "integrity": "sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1957,41 +2001,42 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260120.0", - "@cloudflare/workerd-darwin-arm64": "1.20260120.0", - "@cloudflare/workerd-linux-64": "1.20260120.0", - "@cloudflare/workerd-linux-arm64": "1.20260120.0", - "@cloudflare/workerd-windows-64": "1.20260120.0" + "@cloudflare/workerd-darwin-64": "1.20260903.1", + "@cloudflare/workerd-darwin-arm64": "1.20260903.1", + "@cloudflare/workerd-linux-64": "1.20260903.1", + "@cloudflare/workerd-linux-arm64": "1.20260903.1", + "@cloudflare/workerd-windows-64": "1.20260903.1" } }, "node_modules/wrangler": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.60.0.tgz", - "integrity": "sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g==", + "version": "4.129.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.129.0.tgz", + "integrity": "sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.11.0", + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", - "esbuild": "0.27.0", - "miniflare": "4.20260120.0", + "esbuild": "0.28.1", + "miniflare": "5.20260903.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260120.0" + "workerd": "1.20260903.1" }, "bin": { + "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260120.0" + "@cloudflare/workers-types": "^5.20260903.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -2000,9 +2045,9 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -2045,16 +2090,6 @@ "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 00ee5fbba..03ec01f96 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -10,16 +10,20 @@ "dev": "wrangler dev", "predeploy": "npm run build --prefix ../remote-control-protocol", "deploy": "wrangler deploy", + "pretest": "npm run build --prefix ../remote-control-protocol", "test": "node --import tsx --test src/**/*.test.ts", + "pretest:e2e": "node ./scripts/prepare-e2e-assets.mjs && npm run build --prefix ../remote-control-protocol", + "test:e2e": "node --import tsx --test scripts/remote-control-e2e.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@codenomad/remote-control-protocol": "file:../remote-control-protocol" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260702.1", + "@cloudflare/workers-types": "^5.20260903.1", "tsx": "^4.20.6", "typescript": "^5.6.3", - "wrangler": "^4.0.0" + "undici": "^6.19.8", + "wrangler": "^4.129.0" } } diff --git a/packages/cloudflare/scripts/prepare-e2e-assets.mjs b/packages/cloudflare/scripts/prepare-e2e-assets.mjs new file mode 100644 index 000000000..ae5866787 --- /dev/null +++ b/packages/cloudflare/scripts/prepare-e2e-assets.mjs @@ -0,0 +1,8 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const dist = path.join(packageRoot, "dist") +fs.mkdirSync(dist, { recursive: true }) +fs.writeFileSync(path.join(dist, "version.json"), "{}\n", "utf8") diff --git a/packages/cloudflare/scripts/release-ui.mjs b/packages/cloudflare/scripts/release-ui.mjs index bfa202bc7..ce0f09e26 100644 --- a/packages/cloudflare/scripts/release-ui.mjs +++ b/packages/cloudflare/scripts/release-ui.mjs @@ -27,6 +27,7 @@ if (!uiVersion) { } const uiBuildDir = path.join(repoRoot, "packages/ui/src/renderer/dist") +const workerAssetsDir = path.join(root, "dist") if (!fs.existsSync(uiBuildDir)) { console.error(`Missing UI build dir: ${uiBuildDir}. Run UI build first.`) process.exit(1) @@ -49,6 +50,10 @@ try { { cwd: root, stdio: "inherit" }, ) + // Rebuild Worker assets from scratch so obsolete hashed bundles are not + // retained across releases. + fs.rmSync(workerAssetsDir, { recursive: true, force: true }) + // Generate version.json into packages/cloudflare/dist console.log("[release-ui] Generating version.json") execFileSync( @@ -64,6 +69,10 @@ try { }, ) + // Remote Control loads the same reviewed UI bundle from the relay origin. + // API traffic then travels through the browser-to-host encrypted tunnel. + fs.cpSync(uiBuildDir, workerAssetsDir, { recursive: true }) + console.log("[release-ui] Deploying worker") execFileSync("npx", ["wrangler", "deploy"], { cwd: root, diff --git a/packages/cloudflare/scripts/remote-control-e2e.test.ts b/packages/cloudflare/scripts/remote-control-e2e.test.ts new file mode 100644 index 000000000..aeeb8487f --- /dev/null +++ b/packages/cloudflare/scripts/remote-control-e2e.test.ts @@ -0,0 +1,406 @@ +import assert from "node:assert/strict" +import { createHash } from "node:crypto" +import { once } from "node:events" +import { mkdtempSync, rmSync } from "node:fs" +import { createServer, type IncomingMessage } from "node:http" +import { createServer as createNetServer, type Socket } from "node:net" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { spawn, spawnSync, type ChildProcess } from "node:child_process" +import test from "node:test" +import { createClientHandshake, decodeBase64, encodeBase64, type EncryptedChannel, type HostToClientMessage } from "@codenomad/remote-control-protocol" +import { WebSocket } from "undici" +import { loadOrCreateRemoteControlIdentity } from "../../server/src/remote-control/identity" +import { RemoteControlManager } from "../../server/src/remote-control/manager" + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)) +const wranglerBin = join(packageRoot, "node_modules", "wrangler", "bin", "wrangler.js") + +test("hibernating relay carries opaque HTTP streams and WebSockets end to end", async () => { + const port = await availablePort() + const stateDirectory = mkdtempSync(join(tmpdir(), "codenomad-relay-state-")) + const identityDirectory = mkdtempSync(join(tmpdir(), "codenomad-relay-identity-")) + const local = createLocalServer() + local.server.listen(0, "127.0.0.1") + await once(local.server, "listening") + const address = local.server.address() + assert(address && typeof address !== "string") + const relay = startRelay(port, stateDirectory) + const identity = loadOrCreateRemoteControlIdentity(identityDirectory) + const manager = new RemoteControlManager({ + identity, + relayUrl: `http://localhost:${port}`, + localUrl: () => `http://127.0.0.1:${address.port}`, + localCookie: () => "local=session", + logger: silentLogger() as never, + }) + + try { + await waitForRelay(port, relay) + const started = await manager.start() + const routeHeaders = { "x-codenomad-relay-test-host": `${identity.hostId}.localhost` } + await replaceHostConnection(port, identity.hostId, identity.secret) + await waitForCondition(() => manager.status().state !== "connected", "Connector did not observe host replacement") + await waitForCondition(() => manager.status().state === "connected", "Connector did not reconnect") + + const stalePairing = decodePairing(started.pairing.url) + const staleExchange = await fetch(`http://127.0.0.1:${port}/__codenomad/pair`, { + method: "POST", + headers: { ...routeHeaders, "content-type": "application/json" }, + body: JSON.stringify({ token: stalePairing.token, name: "Stale pairing" }), + }) + assert.equal(staleExchange.status, 401) + const pairing = decodePairing((await manager.createPairing()).url) + const oversized = await fetch(`http://127.0.0.1:${port}/__codenomad/pair`, { + method: "POST", + headers: { ...routeHeaders, "content-type": "application/json" }, + body: JSON.stringify({ token: "x".repeat(5_000) }), + }) + assert.equal(oversized.status, 400) + + const paired = await fetch(`http://127.0.0.1:${port}/__codenomad/pair`, { + method: "POST", + headers: { ...routeHeaders, "content-type": "application/json" }, + body: JSON.stringify({ token: pairing.token, name: "E2E device" }), + }) + assert.equal(paired.status, 204) + const cookie = paired.headers.get("set-cookie") + assert(cookie) + const replayedPairing = await fetch(`http://127.0.0.1:${port}/__codenomad/pair`, { + method: "POST", + headers: { ...routeHeaders, "content-type": "application/json" }, + body: JSON.stringify({ token: pairing.token, name: "Replay" }), + }) + assert.equal(replayedPairing.status, 401) + + const directApi = await fetch(`http://127.0.0.1:${port}/api/workspaces`, { headers: { ...routeHeaders, cookie } }) + assert.equal(directApi.status, 426) + + const client = await EncryptedRelayClient.connect(port, routeHeaders, cookie, pairing.hostPublicKey) + const extraClients: EncryptedRelayClient[] = [] + try { + const streamId = crypto.randomUUID() + const fastId = crypto.randomUUID() + await client.send({ type: "http.request", id: streamId, method: "GET", path: "/api/stream", headers: [] }) + await client.send({ + type: "http.request", + id: fastId, + method: "POST", + path: "/api/fast?kept=1", + headers: [["authorization", "must-not-reach-local"]], + body: encodeBase64(new TextEncoder().encode("encrypted request body")), + }) + const responses = await client.collectHttp([streamId, fastId]) + assert.equal(responses.get(streamId), "stream-start-stream-end") + assert.deepEqual(client.httpCompletionOrder, [fastId, streamId]) + const fast = JSON.parse(responses.get(fastId) ?? "") as Record + assert.equal(fast.url, "/api/fast?kept=1") + assert.equal(fast.body, "encrypted request body") + assert.equal(fast.remote, "1") + assert.equal(fast.authorization, undefined) + assert.equal(fast.cookie, "local=session") + + const socketId = crypto.randomUUID() + await client.send({ type: "socket.open", id: socketId, path: "/workspaces/socket", headers: [["authorization", "strip-me"]], protocols: [] }) + await client.waitFor((message) => message.type === "socket.ready" && message.id === socketId) + await client.send({ type: "socket.message", id: socketId, data: encodeBase64(new TextEncoder().encode("hello")), binary: false }) + const socketReply = await client.waitFor((message) => message.type === "socket.message" && message.id === socketId) + assert.equal(new TextDecoder().decode(decodeBase64(socketReply.data)), "echo:hello") + assert.equal(local.socketHeaders.cookie, "local=session") + assert.equal(local.socketHeaders.authorization, undefined) + await client.send({ type: "socket.close", id: socketId, code: 1000, reason: "done" }) + + for (let index = 0; index < 15; index += 1) { + extraClients.push(await EncryptedRelayClient.connect(port, routeHeaders, cookie, pairing.hostPublicKey)) + } + await assert.rejects( + () => EncryptedRelayClient.connect(port, routeHeaders, cookie, pairing.hostPublicKey), + /failed|timed out/i, + ) + + for (let index = 0; index < 8; index += 1) await manager.createPairing() + await assert.rejects(() => manager.createPairing(), /Too many active pairing links/) + + const devices = await manager.devices() + assert.equal(devices.length, 1) + await manager.revokeDevice(devices[0].id) + await client.waitForClose() + const revoked = await fetch(`http://127.0.0.1:${port}/__codenomad/bootstrap`, { headers: { ...routeHeaders, cookie } }) + assert.equal(revoked.status, 401) + } finally { + client.close() + for (const extra of extraClients) extra.close() + } + } finally { + await manager.shutdown() + local.server.close() + await once(local.server, "close") + stopRelay(relay) + rmSync(stateDirectory, { recursive: true, force: true }) + rmSync(identityDirectory, { recursive: true, force: true }) + } +}) + +class EncryptedRelayClient { + readonly httpCompletionOrder: string[] = [] + private readonly messages: HostToClientMessage[] = [] + private readonly waiters: Array<{ + predicate: (message: HostToClientMessage) => boolean + resolve: (message: HostToClientMessage) => void + timeout: ReturnType + }> = [] + private receiveQueue = Promise.resolve() + private sendQueue = Promise.resolve() + + private constructor( + private readonly socket: InstanceType, + private readonly channel: EncryptedChannel, + ) { + socket.addEventListener("message", (event) => { + if (!(event.data instanceof ArrayBuffer)) return + this.receiveQueue = this.receiveQueue.then(async () => { + const plaintext = await channel.decrypt(new Uint8Array(event.data)) + this.push(JSON.parse(new TextDecoder().decode(plaintext)) as HostToClientMessage) + }) + }) + } + + static async connect(port: number, routeHeaders: Record, cookie: string, hostPublicKey: JsonWebKey): Promise { + const handshake = await createClientHandshake(hostPublicKey) + const socket = new WebSocket(`ws://127.0.0.1:${port}/__codenomad/tunnel`, { headers: { ...routeHeaders, cookie } }) + socket.binaryType = "arraybuffer" + const channel = await new Promise((resolve, reject) => { + const fail = (error: Error) => { + clearTimeout(timeout) + if (socket.readyState < WebSocket.CLOSING) socket.close() + reject(error) + } + const timeout = setTimeout(() => fail(new Error("Encrypted relay handshake timed out")), 15_000) + socket.addEventListener("open", () => socket.send(handshake.hello), { once: true }) + socket.addEventListener("error", () => fail(new Error("Encrypted relay WebSocket failed")), { once: true }) + socket.addEventListener("close", () => fail(new Error("Encrypted relay WebSocket closed during handshake")), { once: true }) + socket.addEventListener("message", (event) => { + if (typeof event.data !== "string") return + void handshake.accept(event.data).then((value) => { + clearTimeout(timeout) + resolve(value) + }).catch(reject) + }, { once: true }) + }) + return new EncryptedRelayClient(socket, channel) + } + + send(message: unknown): Promise { + const plaintext = new TextEncoder().encode(JSON.stringify(message)) + this.sendQueue = this.sendQueue.then(async () => this.socket.send(await this.channel.encrypt(plaintext))) + return this.sendQueue + } + + async collectHttp(ids: string[]): Promise> { + const pending = new Set(ids) + const bodies = new Map(ids.map((id) => [id, ""])) + while (pending.size) { + const message = await this.waitFor((candidate) => "id" in candidate && pending.has(candidate.id)) + if (message.type === "http.chunk") { + bodies.set(message.id, `${bodies.get(message.id)}${new TextDecoder().decode(decodeBase64(message.data))}`) + } else if (message.type === "http.error") { + throw new Error(message.message) + } else if (message.type === "http.end") { + pending.delete(message.id) + } + } + return bodies + } + + waitFor(predicate: (message: HostToClientMessage) => boolean): Promise { + const index = this.messages.findIndex(predicate) + if (index >= 0) return Promise.resolve(this.messages.splice(index, 1)[0]) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const index = this.waiters.findIndex((waiter) => waiter.timeout === timeout) + if (index >= 0) this.waiters.splice(index, 1) + reject(new Error("Timed out waiting for encrypted relay response")) + }, 10_000) + this.waiters.push({ predicate, resolve, timeout }) + }) + } + + waitForClose(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) return Promise.resolve() + return new Promise((resolve) => this.socket.addEventListener("close", () => resolve(), { once: true })) + } + + close(): void { + if (this.socket.readyState < WebSocket.CLOSING) this.socket.close() + } + + private push(message: HostToClientMessage): void { + if (message.type === "http.end") this.httpCompletionOrder.push(message.id) + const index = this.waiters.findIndex((waiter) => waiter.predicate(message)) + if (index < 0) this.messages.push(message) + else { + const waiter = this.waiters.splice(index, 1)[0] + clearTimeout(waiter.timeout) + waiter.resolve(message) + } + } +} + +function createLocalServer() { + const socketHeaders: Record = {} + const server = createServer((request, response) => { + if (request.headers.cookie !== "local=session") { + response.writeHead(401).end("missing internal session") + return + } + if (request.url === "/api/stream") { + response.write("stream-start-") + setTimeout(() => response.end("stream-end"), 150) + return + } + const chunks: Buffer[] = [] + request.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + request.on("end", () => response.end(JSON.stringify({ + url: request.url, + body: Buffer.concat(chunks).toString(), + remote: request.headers["x-codenomad-remote-control"], + authorization: request.headers.authorization, + cookie: request.headers.cookie, + }))) + }) + server.on("upgrade", (request, socket) => { + socketHeaders.cookie = request.headers.cookie + socketHeaders.authorization = request.headers.authorization + acceptEchoSocket(request, socket) + }) + return { server, socketHeaders } +} + +function acceptEchoSocket(request: IncomingMessage, socket: Socket): void { + const key = request.headers["sec-websocket-key"] + if (request.url !== "/workspaces/socket" || typeof key !== "string" || request.headers.cookie !== "local=session") { + socket.destroy() + return + } + const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64") + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`) + socket.on("data", (frame) => { + if ((frame[0] & 0x0f) === 0x08) { + socket.end() + return + } + const payload = decodeClientFrame(frame) + socket.write(serverTextFrame(`echo:${payload}`)) + }) +} + +function decodeClientFrame(frame: Buffer): string { + const length = frame[1] & 0x7f + assert(length < 126) + const mask = frame.subarray(2, 6) + const payload = frame.subarray(6, 6 + length) + for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4] + return payload.toString() +} + +function serverTextFrame(value: string): Buffer { + const payload = Buffer.from(value) + assert(payload.length < 126) + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]) +} + +function decodePairing(urlValue: string): { token: string; hostPublicKey: JsonWebKey } { + const fragment = decodeURIComponent(new URL(urlValue).hash.slice(1)) + const value = JSON.parse(new TextDecoder().decode(decodeBase64(fragment))) as Record + assert.equal(value.protocol, 2) + assert.equal(typeof value.token, "string") + assert.equal(typeof value.hostPublicKey, "object") + return value as { token: string; hostPublicKey: JsonWebKey } +} + +function startRelay(port: number, stateDirectory: string): ChildProcess { + return spawn(process.execPath, [ + wranglerBin, + "dev", + "--local", + "--port", + String(port), + "--persist-to", + stateDirectory, + "--var", + "REMOTE_BASE_HOST:localhost", + ], { + cwd: packageRoot, + env: { ...process.env, NO_COLOR: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }) +} + +async function replaceHostConnection(port: number, hostId: string, secret: string): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${port}/api/hosts/${hostId}/connect`, { + headers: { Authorization: `Bearer ${secret}` }, + }) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Replacement host did not connect")), 10_000) + socket.addEventListener("open", () => socket.send(JSON.stringify({ type: "ready", protocol: 2 })), { once: true }) + socket.addEventListener("message", () => { + clearTimeout(timeout) + resolve() + }, { once: true }) + socket.addEventListener("error", () => reject(new Error("Replacement host failed")), { once: true }) + }) +} + +async function waitForCondition(condition: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!condition()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise((resolve) => setTimeout(resolve, 25)) + } +} + +async function waitForRelay(port: number, relay: ChildProcess): Promise { + const output: Buffer[] = [] + relay.stdout?.on("data", (chunk) => output.push(Buffer.from(chunk))) + relay.stderr?.on("data", (chunk) => output.push(Buffer.from(chunk))) + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + if (relay.exitCode !== null) throw new Error(`Wrangler exited early:\n${Buffer.concat(output).toString()}`) + try { + const response = await fetch(`http://127.0.0.1:${port}/version.json`) + if (response.status < 500) return + } catch { + // Wrangler is still starting. + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`Wrangler did not start:\n${Buffer.concat(output).toString()}`) +} + +function stopRelay(relay: ChildProcess): void { + if (!relay.pid || relay.exitCode !== null) return + if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(relay.pid), "/T", "/F"], { stdio: "ignore" }) + else relay.kill("SIGTERM") +} + +async function availablePort(): Promise { + const server = createNetServer() + server.listen(0, "127.0.0.1") + await once(server, "listening") + const address = server.address() + assert(address && typeof address !== "string") + server.close() + await once(server, "close") + return address.port +} + +function silentLogger() { + return { + child() { return this }, + debug() {}, + error() {}, + info() {}, + warn() {}, + } +} diff --git a/packages/cloudflare/src/index.test.ts b/packages/cloudflare/src/index.test.ts index 3952b5a18..8715122e6 100644 --- a/packages/cloudflare/src/index.test.ts +++ b/packages/cloudflare/src/index.test.ts @@ -2,7 +2,12 @@ import assert from "node:assert/strict" import test from "node:test" import worker, { type Env } from "./index" -function relayEnv(onRequest: (request: Request) => Response | Promise): Env { +const DEVICE_COOKIE = `codenomad_remote_device=${"d".repeat(43)}` + +function relayEnv( + onRequest: (request: Request) => Response | Promise, + onAsset: (request: Request) => Response | Promise = () => new Response("asset"), +): Env { const stub = { fetch: onRequest } return { REMOTE_BASE_HOST: "remote.example.com", @@ -10,27 +15,118 @@ function relayEnv(onRequest: (request: Request) => Response | Promise) idFromName: (name: string) => name, get: () => stub, } as unknown as DurableObjectNamespace, - ASSETS: { fetch: () => new Response("asset") } as Fetcher, + ASSETS: { fetch: onAsset } as Fetcher, } } -test("relay operations use internal headers without changing remote query parameters", async () => { +test("remote API paths require a paired session and encrypted tunnel", async () => { let forwarded: Request | undefined const env = relayEnv((request) => { forwarded = request - return new Response("ok") + return new Response(null, { status: 204 }) }) const hostId = "a".repeat(32) - const response = await worker.fetch(new Request(`https://${hostId}.remote.example.com/api/items?operation=client&value=1`), env) - assert.equal(response.status, 200) - assert.equal(new URL(forwarded!.url).search, "?operation=client&value=1") - assert.equal(forwarded!.headers.get("x-codenomad-relay-operation"), "proxy") + const response = await worker.fetch(new Request(`https://${hostId}.remote.example.com/api/items?value=1`, { headers: { cookie: DEVICE_COOKIE } }), env) + assert.equal(response.status, 426) + assert.equal(forwarded!.headers.get("x-codenomad-relay-operation"), "session-check") + assert.match(await response.text(), /Encrypted Remote Control tunnel required/) }) -test("pairing page allows its same-origin exchange and blocks framing", async () => { +test("remote HTML is authenticated and receives the encrypted transport bootstrap", async () => { const hostId = "b".repeat(32) + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/`, { headers: { cookie: DEVICE_COOKIE } }), + relayEnv( + () => new Response(null, { status: 204 }), + () => new Response("", { headers: { "content-type": "text/html" } }), + ), + ) + assert.equal(response.status, 200) + assert.match(await response.text(), /__CODENOMAD_REMOTE_CONTROL__/) + assert.equal(response.headers.get("cache-control"), "no-store") + assert.equal(response.headers.get("x-frame-options"), "DENY") +}) + +test("remote tunnel upgrades are routed directly to the host object", async () => { + let operation: string | null = null + const hostId = "c".repeat(32) + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/__codenomad/tunnel`, { headers: { cookie: DEVICE_COOKIE } }), + relayEnv((request) => { + operation = request.headers.get("x-codenomad-relay-operation") + return new Response("tunnel") + }), + ) + assert.equal(await response.text(), "tunnel") + assert.equal(operation, "tunnel-connect") +}) + +test("remote HTML is not served before device authentication", async () => { + const hostId = "0".repeat(32) + let assetRequests = 0 + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/`), + relayEnv( + () => Response.json({ error: "Remote device is not paired" }, { status: 401 }), + () => { + assetRequests += 1 + return new Response("should not be served") + }, + ), + ) + assert.equal(response.status, 401) + assert.equal(assetRequests, 0) +}) + +test("immutable UI assets do not wake the host object", async () => { + const hostId = "1".repeat(32) + let hostRequests = 0 + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/assets/app.js`), + relayEnv( + () => { + hostRequests += 1 + return new Response(null, { status: 401 }) + }, + () => new Response("console.log('public bundle')", { headers: { "content-type": "text/javascript" } }), + ), + ) + assert.equal(response.status, 200) + assert.equal(hostRequests, 0) +}) + +test("remote bootstrap is authenticated and never cached", async () => { + const hostId = "e".repeat(32) + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/__codenomad/bootstrap`, { headers: { cookie: DEVICE_COOKIE } }), + relayEnv(() => new Response(null, { status: 204 })), + ) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { tunnelPath: "/__codenomad/tunnel" }) + assert.equal(response.headers.get("cache-control"), "no-store") +}) + +test("host control endpoints are accepted only on the relay base origin", async () => { + const hostId = "f".repeat(32) + let forwarded = 0 + const env = relayEnv(() => { + forwarded += 1 + return new Response(null, { status: 204 }) + }) + const allowed = await worker.fetch(new Request(`https://remote.example.com/api/hosts/${hostId}/devices`), env) + assert.equal(allowed.status, 204) + const blocked = await worker.fetch(new Request(`https://ui.example.com/api/hosts/${hostId}/devices`), env) + assert.equal(blocked.status, 200) + assert.equal(forwarded, 1) +}) + +test("pairing page allows its same-origin exchange, stores the pinned host key, and blocks framing", async () => { + const hostId = "d".repeat(32) const response = await worker.fetch(new Request(`https://${hostId}.remote.example.com/__codenomad/pair`), relayEnv(() => new Response())) const policy = response.headers.get("content-security-policy") ?? "" + const page = await response.text() assert.match(policy, /connect-src 'self'/) assert.match(policy, /frame-ancestors 'none'/) + assert.match(page, /codenomad\.remote-control\.host-public-key/) + assert.match(page, /pairing\.protocol!==2/) }) diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 90ae85c99..d2776d15a 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,5 +1,5 @@ import { RemoteControlHost } from "./remote-control/host-object" -import { HOST_ID_PATTERN } from "./remote-control/security" +import { HOST_ID_PATTERN, RELAY_TOKEN_PATTERN, clearDeviceCookie, cookieToken } from "./remote-control/security" export { RemoteControlHost } @@ -13,10 +13,11 @@ export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url) const baseHost = env.REMOTE_BASE_HOST.toLowerCase() - const hostId = remoteHostId(url.hostname, baseHost) + const hostname = requestHostname(request, url, baseHost) + const hostId = remoteHostId(hostname, baseHost) if (hostId) return handleRemoteHost(request, env, hostId) - if (url.hostname.toLowerCase() === baseHost && url.pathname.startsWith("/api/hosts/")) { + if ((hostname === baseHost || baseHost === "localhost") && url.pathname.startsWith("/api/hosts/")) { return handleHostControl(request, env) } @@ -32,6 +33,17 @@ export default { }, } +function requestHostname(request: Request, url: URL, baseHost: string): string { + const hostname = url.hostname.toLowerCase() + if (baseHost === "localhost") { + const testHost = request.headers.get("x-codenomad-relay-test-host") + if (testHost) return testHost.toLowerCase() + } + if (baseHost !== "localhost" || (hostname !== "localhost" && !hostname.startsWith("127."))) return hostname + const presented = (request.headers.get("host") ?? hostname).split(":")[0].toLowerCase() + return presented === "localhost" || presented.startsWith("127.") ? baseHost : presented +} + async function handleHostControl(request: Request, env: Env): Promise { const url = new URL(request.url) const match = url.pathname.match(/^\/api\/hosts\/([a-f0-9]{32})\/(connect|pair|devices)(?:\/([^/]+))?$/) @@ -60,11 +72,86 @@ async function handleRemoteHost(request: Request, env: Env, hostId: string): Pro "Cache-Control": "no-store", "Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", }, }) } - return hostStub(env, hostId).fetch(withOperation(request, "proxy")) + if (url.pathname === "/__codenomad/pair") return new Response(null, { status: 405 }) + + if (url.pathname === "/__codenomad/tunnel") { + if (!validDeviceCookie(request)) return unpairedResponse() + return hostStub(env, hostId).fetch(withOperation(request, "tunnel-connect")) + } + + if (url.pathname === "/__codenomad/bootstrap") { + const authorized = await checkRemoteSession(request, env, hostId) + if (!authorized.ok) return authorized + return Response.json({ tunnelPath: "/__codenomad/tunnel" }, { + headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }, + }) + } + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/workspaces/")) { + const authorized = await checkRemoteSession(request, env, hostId) + if (!authorized.ok) return authorized + return Response.json({ error: "Encrypted Remote Control tunnel required" }, { status: 426 }) + } + if (isHtmlNavigation(request, url)) { + const authorized = await checkRemoteSession(request, env, hostId) + if (!authorized.ok) return authorized + } + return remoteAsset(request, env) +} + +function checkRemoteSession(request: Request, env: Env, hostId: string): Promise { + if (!validDeviceCookie(request)) return Promise.resolve(unpairedResponse()) + return hostStub(env, hostId).fetch(withOperation(request, "session-check")) +} + +function validDeviceCookie(request: Request): boolean { + return RELAY_TOKEN_PATTERN.test(cookieToken(request) ?? "") +} + +function unpairedResponse(): Response { + return Response.json({ error: "Remote device is not paired" }, { + status: 401, + headers: { "Set-Cookie": clearDeviceCookie() }, + }) +} + +function isHtmlNavigation(request: Request, url: URL): boolean { + return url.pathname === "/" || url.pathname.endsWith(".html") + || request.headers.get("accept")?.includes("text/html") === true +} + +async function remoteAsset(request: Request, env: Env): Promise { + const response = await env.ASSETS.fetch(request) + const headers = new Headers(response.headers) + headers.set("Referrer-Policy", "no-referrer") + headers.set("X-Content-Type-Options", "nosniff") + headers.set("X-Frame-Options", "DENY") + const url = new URL(request.url) + const isHtml = response.headers.get("content-type")?.toLowerCase().includes("text/html") + || url.pathname === "/" + || url.pathname.endsWith(".html") + if (!isHtml) { + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) + } + if (request.method === "HEAD") { + return new Response(null, { status: response.status, statusText: response.statusText, headers }) + } + const html = await response.text() + const bootstrap = "" + headers.set("Cache-Control", "no-store") + headers.delete("Content-Length") + headers.delete("Content-Encoding") + headers.delete("ETag") + const bootstrappedHtml = html.includes("") ? html.replace("", `${bootstrap}`) : `${bootstrap}${html}` + return new Response(bootstrappedHtml, { + status: response.status, + statusText: response.statusText, + headers, + }) } function hostStub(env: Env, hostId: string): DurableObjectStub { @@ -108,8 +195,18 @@ function pairingPage(acceptLanguage: string | null): string { const messages=${messages}; const status=document.getElementById('status'); status.textContent=messages.connecting; -const token=decodeURIComponent(location.hash.slice(1)); +let pairing; +try { + const encoded=decodeURIComponent(location.hash.slice(1)); + const bytes=Uint8Array.from(atob(encoded),character=>character.charCodeAt(0)); + pairing=JSON.parse(new TextDecoder().decode(bytes)); + const key=pairing.hostPublicKey; + if(pairing.protocol!==2||typeof pairing.token!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(pairing.token)||!key||key.kty!=='EC'||key.crv!=='P-256'||typeof key.x!=='string'||typeof key.y!=='string')throw new Error(messages.failed); +} catch(error) { + status.textContent=messages.failed; + throw error; +} history.replaceState(null,'',location.pathname); -fetch(location.pathname,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token,name:navigator.userAgent.includes('Mobile')?messages.mobile:messages.browser})}).then(response=>{if(!response.ok)throw new Error(messages.failed);location.replace('/')}).catch(error=>{status.textContent=error.message}); +fetch(location.pathname,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token:pairing.token,name:navigator.userAgent.includes('Mobile')?messages.mobile:messages.browser})}).then(response=>{if(!response.ok)throw new Error(messages.failed);localStorage.setItem('codenomad.remote-control.host-public-key',JSON.stringify(pairing.hostPublicKey));location.replace('/')}).catch(error=>{status.textContent=error.message}); ` } diff --git a/packages/cloudflare/src/remote-control/headers.ts b/packages/cloudflare/src/remote-control/headers.ts deleted file mode 100644 index bd93bdc55..000000000 --- a/packages/cloudflare/src/remote-control/headers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { HeaderEntries } from "@codenomad/remote-control-protocol" - -const REQUEST_BLOCKLIST = new Set([ - "authorization", - "cf-connecting-ip", - "cf-ipcountry", - "cf-ray", - "cf-visitor", - "connection", - "cookie", - "host", - "origin", - "proxy-authorization", - "proxy-connection", - "sec-websocket-extensions", - "sec-websocket-key", - "sec-websocket-version", - "transfer-encoding", - "upgrade", - "x-forwarded-for", - "x-forwarded-host", - "x-forwarded-proto", - "x-codenomad-relay-device-id", - "x-codenomad-relay-operation", -]) - -const RESPONSE_BLOCKLIST = new Set([ - "connection", - "content-encoding", - "content-length", - "set-cookie", - "transfer-encoding", - "upgrade", -]) - -export function relayRequestHeaders(headers: Headers): HeaderEntries { - const result: HeaderEntries = [] - headers.forEach((value, name) => { - if (!REQUEST_BLOCKLIST.has(name.toLowerCase())) result.push([name, value]) - }) - return result -} - -export function relayResponseHeaders(entries: HeaderEntries): Headers { - const headers = new Headers() - for (const [name, value] of entries) { - if (!RESPONSE_BLOCKLIST.has(name.toLowerCase())) headers.append(name, value) - } - headers.set("Cache-Control", headers.get("Cache-Control") ?? "no-store") - return headers -} diff --git a/packages/cloudflare/src/remote-control/host-object.ts b/packages/cloudflare/src/remote-control/host-object.ts index 8153a2f34..e97eda24f 100644 --- a/packages/cloudflare/src/remote-control/host-object.ts +++ b/packages/cloudflare/src/remote-control/host-object.ts @@ -1,28 +1,34 @@ import { + REMOTE_CONTROL_HEARTBEAT_REQUEST, + REMOTE_CONTROL_HEARTBEAT_RESPONSE, + REMOTE_CONTROL_MAX_HANDSHAKE_BYTES, + REMOTE_CONTROL_MAX_PLAINTEXT_BYTES, REMOTE_CONTROL_PROTOCOL_VERSION, decodeBase64, encodeBase64, - type HostToRelayMessage, type RelayToHostMessage, } from "@codenomad/remote-control-protocol" -import { relayRequestHeaders, relayResponseHeaders } from "./headers" -import { bearerToken, clearDeviceCookie, cookieToken, deviceCookie, randomToken, tokenHash } from "./security" +import { base64ByteLength, parseHostMessage, readPairingInput, safeRelayCloseCode } from "./relay-messages" +import { HOST_SECRET_PATTERN, RELAY_TOKEN_PATTERN, bearerToken, clearDeviceCookie, cookieToken, deviceCookie, randomToken, tokenHash } from "./security" const PAIRING_TTL_MS = 10 * 60_000 const DEVICE_TTL_MS = 30 * 24 * 60 * 60_000 const HOST_SECRET_KEY = "host-secret" const PAIRING_PREFIX = "pair:" const DEVICE_PREFIX = "device:" -const SOCKET_HANDSHAKE_TIMEOUT_MS = 15_000 -const HTTP_RESPONSE_TIMEOUT_MS = 30_000 -const MAX_QUEUED_SOCKET_MESSAGES = 256 -const MAX_HTTP_REQUEST_BODY_BYTES = 20 * 1024 * 1024 +const HOST_TAG = "host" +const CLIENT_TAG = "client" +const MAX_ACTIVE_PAIRINGS = 8 +const MAX_CONNECTED_CLIENTS = 16 +const MAX_DEVICES = 64 +const MAX_PAIRING_BODY_BYTES = 4 * 1024 +const MAX_TUNNEL_FRAME_BYTES = REMOTE_CONTROL_MAX_PLAINTEXT_BYTES + 1024 +const MAX_HOST_MESSAGE_CHARS = Math.ceil(MAX_TUNNEL_FRAME_BYTES * 4 / 3) + 2 * 1024 interface PairingRecord { expiresAt: number connectionId: string } - interface DeviceRecord { id: string name: string @@ -31,46 +37,125 @@ interface DeviceRecord { expiresAt: number } -interface PendingHttp { - resolve: (value: { status: number; headers: Headers; stream: ReadableStream }) => void - reject: (reason: Error) => void - controller?: ReadableStreamDefaultController - queued: Uint8Array[] - ended: boolean - deviceId: string - timeout: ReturnType +interface HostSocketAttachment { + role: "host" + connectionId: string + ready: boolean + active: boolean } -interface PendingSocket { - client: WebSocket - ready: boolean - queued: Array<{ data: string; binary: boolean }> - resolveReady: (protocol?: string) => void - rejectReady: (error: Error) => void +interface ClientSocketAttachment { + role: "client" + id: string deviceId: string + phase?: "hello" | "encrypted" } -export class RemoteControlHost implements DurableObject { - private hostSocket: WebSocket | null = null - private hostConnectionId: string | null = null - private hostReady = false - private readonly pendingHttp = new Map() - private readonly pendingSockets = new Map() +type SocketAttachment = HostSocketAttachment | ClientSocketAttachment - constructor(private readonly state: DurableObjectState) {} +export class RemoteControlHost implements DurableObject { + constructor(private readonly state: DurableObjectState) { + state.setWebSocketAutoResponse(new WebSocketRequestResponsePair( + REMOTE_CONTROL_HEARTBEAT_REQUEST, + REMOTE_CONTROL_HEARTBEAT_RESPONSE, + )) + } async fetch(request: Request): Promise { const operation = request.headers.get("x-codenomad-relay-operation") - if (operation === "host-connect") return this.connectHost(request) if (operation === "pair-create") return this.createPairing(request) if (operation === "pair-exchange") return this.exchangePairing(request) if (operation === "devices") return this.devices(request) if (operation === "device-revoke") return this.revokeDevice(request) - if (operation === "proxy") return this.proxy(request) + if (operation === "session-check") return this.checkDevice(request) + if (operation === "tunnel-connect") return this.connectTunnel(request) return Response.json({ error: "Unknown remote-control operation" }, { status: 404 }) } + webSocketMessage(socket: WebSocket, payload: string | ArrayBuffer): void { + const attachment = socketAttachment(socket) + if (!attachment) { + socket.close(1008, "Missing relay socket identity") + return + } + if (attachment.role === "host") { + this.onHostMessage(socket, payload) + return + } + + const bytes = typeof payload === "string" ? new TextEncoder().encode(payload) : new Uint8Array(payload) + if ((attachment.phase === undefined && typeof payload !== "string") + || attachment.phase === "hello" + || (attachment.phase === "encrypted" && typeof payload === "string")) { + this.closeClient(attachment.id, 1002, "Invalid encrypted tunnel sequence") + return + } + if (bytes.byteLength > (attachment.phase === "encrypted" ? MAX_TUNNEL_FRAME_BYTES : REMOTE_CONTROL_MAX_HANDSHAKE_BYTES)) { + this.closeClient(attachment.id, 1009, attachment.phase === "encrypted" + ? "Encrypted tunnel frame is too large" + : "Encryption handshake is too large") + return + } + if (!this.sendHost({ + type: "tunnel.message", + id: attachment.id, + data: encodeBase64(bytes), + binary: typeof payload !== "string", + })) { + this.closeClient(attachment.id, 1013, "CodeNomad host disconnected") + } else if (attachment.phase !== "encrypted") { + attachment.phase = "hello" + socket.serializeAttachment(attachment) + } + } + + webSocketClose(socket: WebSocket, code: number, reason: string): void { + const attachment = socketAttachment(socket) + if (!attachment) return + if (attachment.role === "host") { + this.onHostClosed(socket) + return + } + this.sendHost({ type: "tunnel.close", id: attachment.id, code, reason }) + } + + webSocketError(socket: WebSocket): void { + const attachment = socketAttachment(socket) + if (!attachment) return + if (attachment.role === "host") { + this.onHostClosed(socket) + return + } + this.sendHost({ type: "tunnel.close", id: attachment.id, code: 1011, reason: "Remote tunnel failed" }) + } + + async alarm(): Promise { + const now = Date.now() + const pairings = await this.state.storage.list({ prefix: PAIRING_PREFIX }) + const devices = await this.state.storage.list({ prefix: DEVICE_PREFIX }) + const expiredDevices = Array.from(devices.entries()).filter(([, record]) => record.expiresAt <= now) + const expired = [ + ...Array.from(pairings.entries()).filter(([, record]) => record.expiresAt <= now).map(([key]) => key), + ...expiredDevices.map(([key]) => key), + ] + if (expired.length) await this.state.storage.delete(expired) + const expiredDeviceIds = new Set(expiredDevices.map(([, record]) => record.id)) + if (expiredDeviceIds.size) { + for (const socket of this.state.getWebSockets(CLIENT_TAG)) { + const attachment = socketAttachment(socket) + if (attachment?.role === "client" && expiredDeviceIds.has(attachment.deviceId)) { + this.closeClient(attachment.id, 1008, "Remote device expired") + } + } + } + const nextExpiration = [...pairings.values(), ...devices.values()] + .map((record) => record.expiresAt) + .filter((expiresAt) => expiresAt > now) + .sort((left, right) => left - right)[0] + if (nextExpiration) await this.state.storage.setAlarm(nextExpiration) + } + private async connectHost(request: Request): Promise { if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { return Response.json({ error: "WebSocket required" }, { status: 426 }) @@ -80,42 +165,79 @@ export class RemoteControlHost implements DurableObject { const pair = new WebSocketPair() const client = pair[0] const server = pair[1] - server.accept() - - const previous = this.hostSocket - if (previous) this.failPending("CodeNomad host reconnected") - this.hostSocket = server - this.hostConnectionId = crypto.randomUUID() - this.hostReady = false - previous?.close(1012, "Host reconnected") - server.addEventListener("message", (event) => this.onHostMessage(server, event)) - server.addEventListener("close", () => this.onHostClosed(server)) - server.addEventListener("error", () => this.onHostClosed(server)) + const previous = this.hostConnection() + if (previous) { + previous.attachment.active = false + previous.socket.serializeAttachment(previous.attachment) + previous.socket.close(1012, "Host reconnected") + this.closeAllClients("CodeNomad host reconnected") + } + const attachment: HostSocketAttachment = { + role: "host", + connectionId: crypto.randomUUID(), + ready: false, + active: true, + } + server.serializeAttachment(attachment) + this.state.acceptWebSocket(server, [HOST_TAG]) + return new Response(null, { status: 101, webSocket: client }) + } + private async connectTunnel(request: Request): Promise { + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return Response.json({ error: "WebSocket required" }, { status: 426 }) + } + const device = await this.authorizeDevice(request) + if (!device) return this.unpairedResponse() + if (!this.isHostConnected()) return Response.json({ error: "CodeNomad host is offline" }, { status: 503 }) + if (this.state.getWebSockets(CLIENT_TAG).filter((socket) => socket.readyState === WebSocket.OPEN).length >= MAX_CONNECTED_CLIENTS) { + return Response.json({ error: "Too many active remote clients" }, { status: 429 }) + } + + const id = crypto.randomUUID() + const pair = new WebSocketPair() + const client = pair[0] + const server = pair[1] + const attachment: ClientSocketAttachment = { role: "client", id, deviceId: device.id } + server.serializeAttachment(attachment) + this.state.acceptWebSocket(server, [CLIENT_TAG, clientTag(id)]) + if (!this.sendHost({ type: "tunnel.open", id })) { + server.close(1013, "CodeNomad host disconnected") + return Response.json({ error: "CodeNomad host is offline" }, { status: 503 }) + } return new Response(null, { status: 101, webSocket: client }) } private async createPairing(request: Request): Promise { if (request.method !== "POST") return new Response(null, { status: 405 }) if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) - if (!this.isHostConnected()) return Response.json({ error: "Host is offline" }, { status: 409 }) + const host = this.hostConnection() + if (!host?.attachment.ready) return Response.json({ error: "Host is offline" }, { status: 409 }) + + const pairings = await this.state.storage.list({ prefix: PAIRING_PREFIX }) + const now = Date.now() + const expired = Array.from(pairings.entries()).filter(([, record]) => record.expiresAt <= now).map(([key]) => key) + if (expired.length) await this.state.storage.delete(expired) + if (pairings.size - expired.length >= MAX_ACTIVE_PAIRINGS) { + return Response.json({ error: "Too many active pairing links" }, { status: 429 }) + } const token = randomToken() - const expiresAt = Date.now() + PAIRING_TTL_MS + const expiresAt = now + PAIRING_TTL_MS await this.state.storage.put(`${PAIRING_PREFIX}${await tokenHash(token)}`, { expiresAt, - connectionId: this.hostConnectionId!, + connectionId: host.attachment.connectionId, } satisfies PairingRecord) + await this.scheduleExpirationCleanup(expiresAt) return Response.json({ token, expiresAt: new Date(expiresAt).toISOString() }) } private async exchangePairing(request: Request): Promise { if (request.method !== "POST") return new Response(null, { status: 405 }) - const input: { token?: unknown; name?: unknown } = await request - .json<{ token?: unknown; name?: unknown }>() - .catch(() => ({})) + const input = await readPairingInput(request, MAX_PAIRING_BODY_BYTES) + if (!input) return Response.json({ error: "Invalid or oversized pairing request" }, { status: 400 }) const token = typeof input.token === "string" ? input.token.trim() : "" - if (!token) return Response.json({ error: "Pairing token required" }, { status: 400 }) + if (!RELAY_TOKEN_PATTERN.test(token)) return Response.json({ error: "Valid pairing token required" }, { status: 400 }) const key = `${PAIRING_PREFIX}${await tokenHash(token)}` const pairing = await this.state.storage.transaction(async (transaction) => { @@ -123,12 +245,18 @@ export class RemoteControlHost implements DurableObject { if (record) await transaction.delete(key) return record }) - if (!pairing || pairing.expiresAt <= Date.now() || !this.isHostConnected() || pairing.connectionId !== this.hostConnectionId) { + const host = this.hostConnection() + if (!pairing || pairing.expiresAt <= Date.now() || !host?.attachment.ready || pairing.connectionId !== host.attachment.connectionId) { return Response.json({ error: "Pairing link is invalid or expired" }, { status: 401 }) } - const deviceToken = randomToken() + const records = await this.state.storage.list({ prefix: DEVICE_PREFIX }) const now = Date.now() + const expired = Array.from(records.entries()).filter(([, device]) => device.expiresAt <= now).map(([recordKey]) => recordKey) + if (expired.length) await this.state.storage.delete(expired) + if (records.size - expired.length >= MAX_DEVICES) return Response.json({ error: "Too many paired devices" }, { status: 429 }) + + const deviceToken = randomToken() const device: DeviceRecord = { id: crypto.randomUUID(), name: typeof input.name === "string" && input.name.trim() ? input.name.trim().slice(0, 80) : "Remote device", @@ -137,6 +265,7 @@ export class RemoteControlHost implements DurableObject { expiresAt: now + DEVICE_TTL_MS, } await this.state.storage.put(`${DEVICE_PREFIX}${await tokenHash(deviceToken)}`, device) + await this.scheduleExpirationCleanup(device.expiresAt) return new Response(null, { status: 204, headers: { "Set-Cookie": deviceCookie(deviceToken, Math.floor(DEVICE_TTL_MS / 1000)) }, @@ -168,264 +297,130 @@ export class RemoteControlHost implements DurableObject { const entry = Array.from(records.entries()).find(([, device]) => device.id === deviceId) if (entry) { await this.state.storage.delete(entry[0]) - for (const [id, pending] of this.pendingHttp) { - if (pending.deviceId !== deviceId) continue - this.sendHost({ type: "http.cancel", id }) - this.failHttp(id, new Error("Remote device was revoked")) - } - for (const [id, pending] of this.pendingSockets) { - if (pending.deviceId === deviceId) this.closeSocket(id, "Remote device was revoked", 1008) + for (const socket of this.state.getWebSockets(CLIENT_TAG)) { + const attachment = socketAttachment(socket) + if (attachment?.role === "client" && attachment.deviceId === deviceId) { + this.closeClient(attachment.id, 1008, "Remote device was revoked") + } } } return new Response(null, { status: 204 }) } - private async proxy(request: Request): Promise { - const device = await this.authorizeDevice(request) - if (!device) { - return Response.json({ error: "Remote device is not paired" }, { - status: 401, - headers: { "Set-Cookie": clearDeviceCookie() }, - }) - } - if (!this.isHostConnected()) return Response.json({ error: "CodeNomad host is offline" }, { status: 503 }) - if (request.headers.get("upgrade")?.toLowerCase() === "websocket") return this.proxySocket(request, device.id) - return this.proxyHttp(request, device.id) + private async checkDevice(request: Request): Promise { + return await this.authorizeDevice(request) ? new Response(null, { status: 204 }) : this.unpairedResponse() } - private async proxyHttp(request: Request, deviceId: string): Promise { - const id = crypto.randomUUID() - let body: string | undefined - if (request.method !== "GET" && request.method !== "HEAD") { - const bytes = new Uint8Array(await request.arrayBuffer()) - if (bytes.byteLength > MAX_HTTP_REQUEST_BODY_BYTES) { - return Response.json({ error: "Remote request body is too large" }, { status: 413 }) - } - body = encodeBase64(bytes) - } - const message: RelayToHostMessage = { - type: "http.request", - id, - method: request.method, - path: remotePath(request), - headers: relayRequestHeaders(request.headers), - ...(body ? { body } : {}), - } - - const response = new Promise<{ status: number; headers: Headers; stream: ReadableStream }>((resolve, reject) => { - const timeout = setTimeout(() => { - this.sendHost({ type: "http.cancel", id }) - this.failHttp(id, new Error("CodeNomad host response timed out")) - }, HTTP_RESPONSE_TIMEOUT_MS) - this.pendingHttp.set(id, { resolve, reject, queued: [], ended: false, deviceId, timeout }) - }) - request.signal.addEventListener("abort", () => { - this.sendHost({ type: "http.cancel", id }) - this.failHttp(id, new Error("Remote request cancelled")) - }, { once: true }) - if (!this.sendHost(message)) this.failHttp(id, new Error("CodeNomad host disconnected")) - - try { - const result = await response - const body = request.method === "HEAD" || responseMustNotHaveBody(result.status) ? null : result.stream - return new Response(body, { - status: result.status, - headers: result.headers, - }) - } catch (error) { - return Response.json({ error: error instanceof Error ? error.message : "Remote request failed" }, { status: 502 }) + private onHostMessage(socket: WebSocket, payload: string | ArrayBuffer): void { + const current = this.hostConnection() + if (current?.socket !== socket) return + if (typeof payload !== "string") { + socket.close(1003, "Invalid binary host message") + return } - } - - private async proxySocket(request: Request, deviceId: string): Promise { - const id = crypto.randomUUID() - const pair = new WebSocketPair() - const client = pair[0] - const server = pair[1] - server.accept() - const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((value) => value.trim()).filter(Boolean) - let resolveReady!: (protocol?: string) => void - let rejectReady!: (error: Error) => void - const ready = new Promise((resolve, reject) => { - resolveReady = resolve - rejectReady = reject - }) - this.pendingSockets.set(id, { client: server, ready: false, queued: [], resolveReady, rejectReady, deviceId }) - server.addEventListener("message", (event) => { - const binary = typeof event.data !== "string" - const bytes = binary ? new Uint8Array(event.data as ArrayBuffer) : new TextEncoder().encode(event.data as string) - this.sendHost({ type: "socket.message", id, data: encodeBase64(bytes), binary }) - }) - server.addEventListener("close", (event) => { - this.sendHost({ type: "socket.close", id, code: event.code, reason: event.reason }) - const pending = this.pendingSockets.get(id) - if (pending && !pending.ready) pending.rejectReady(new Error("Remote WebSocket client disconnected")) - this.pendingSockets.delete(id) - }) - server.addEventListener("error", () => { - this.sendHost({ type: "socket.close", id, code: 1011, reason: "Client socket failed" }) - const pending = this.pendingSockets.get(id) - if (pending && !pending.ready) pending.rejectReady(new Error("Remote WebSocket client failed")) - this.pendingSockets.delete(id) - }) - const sent = this.sendHost({ - type: "socket.open", - id, - path: remotePath(request), - headers: relayRequestHeaders(request.headers), - protocols, - }) - if (!sent) rejectReady(new Error("CodeNomad host disconnected")) - const timeout = setTimeout(() => rejectReady(new Error("CodeNomad WebSocket handshake timed out")), SOCKET_HANDSHAKE_TIMEOUT_MS) - try { - const protocol = await ready - const headers = protocol ? { "Sec-WebSocket-Protocol": protocol } : undefined - return new Response(null, { status: 101, webSocket: client, headers }) - } catch (error) { - this.pendingSockets.delete(id) - server.close(1013, "CodeNomad host unavailable") - this.sendHost({ type: "socket.close", id, code: 1013, reason: "Remote handshake cancelled" }) - return Response.json({ error: error instanceof Error ? error.message : "WebSocket handshake failed" }, { status: 502 }) - } finally { - clearTimeout(timeout) + if (payload.length > MAX_HOST_MESSAGE_CHARS) { + socket.close(1009, "Relay message is too large") + return } - } - - private onHostMessage(socket: WebSocket, event: MessageEvent) { - if (this.hostSocket !== socket) return - if (typeof event.data !== "string") return - let message: HostToRelayMessage - try { - message = JSON.parse(event.data) as HostToRelayMessage - } catch { - this.hostSocket?.close(1003, "Invalid relay message") + const message = parseHostMessage(payload) + if (!message) { + socket.close(1003, "Invalid relay message") return } - if (message.type === "ready") { if (message.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) { - this.hostSocket?.close(1002, "Unsupported protocol") + socket.close(1002, "Unsupported protocol") return } - this.hostReady = true + current.attachment.ready = true + socket.serializeAttachment(current.attachment) this.sendHost({ type: "ready", protocol: REMOTE_CONTROL_PROTOCOL_VERSION }) return } - if (message.type === "http.start") return this.startHttp(message) - if (message.type === "http.chunk") return this.chunkHttp(message.id, decodeBase64(message.data)) - if (message.type === "http.end") return this.endHttp(message.id) - if (message.type === "http.error") return this.failHttp(message.id, new Error(message.message)) - if (message.type === "socket.ready") return this.readySocket(message.id, message.protocol) - if (message.type === "socket.message") return this.messageSocket(message.id, message.data, message.binary) - if (message.type === "socket.close") return this.closeSocket(message.id, message.reason) - if (message.type === "socket.error") return this.closeSocket(message.id, message.message) - } - - private startHttp(message: Extract) { - const pending = this.pendingHttp.get(message.id) - if (!pending) return - clearTimeout(pending.timeout) - const stream = new ReadableStream({ - start: (controller) => { - pending.controller = controller - for (const chunk of pending.queued) controller.enqueue(chunk) - pending.queued.length = 0 - if (pending.ended) controller.close() - }, - cancel: () => { - this.sendHost({ type: "http.cancel", id: message.id }) - this.pendingHttp.delete(message.id) - }, - }) - pending.resolve({ status: message.status, headers: relayResponseHeaders(message.headers), stream }) - } - - private chunkHttp(id: string, chunk: Uint8Array) { - const pending = this.pendingHttp.get(id) - if (!pending || pending.ended) return - if (pending.controller) pending.controller.enqueue(chunk) - else pending.queued.push(chunk) - } - - private endHttp(id: string) { - const pending = this.pendingHttp.get(id) - if (!pending) return - clearTimeout(pending.timeout) - pending.ended = true - pending.controller?.close() - this.pendingHttp.delete(id) - } - - private failHttp(id: string, error: Error) { - const pending = this.pendingHttp.get(id) - if (!pending) return - clearTimeout(pending.timeout) - pending.reject(error) - pending.controller?.error(error) - this.pendingHttp.delete(id) - } - - private readySocket(id: string, protocol?: string) { - const pending = this.pendingSockets.get(id) - if (!pending) return - pending.ready = true - pending.resolveReady(protocol) - for (const entry of pending.queued) this.sendSocket(pending.client, entry.data, entry.binary) - pending.queued.length = 0 - } - - private messageSocket(id: string, data: string, binary: boolean) { - const pending = this.pendingSockets.get(id) - if (!pending) return - if (!pending.ready && pending.queued.length >= MAX_QUEUED_SOCKET_MESSAGES) { - this.closeSocket(id, "Too many queued CodeNomad messages", 1009) - } else if (!pending.ready) pending.queued.push({ data, binary }) - else this.sendSocket(pending.client, data, binary) - } - - private sendSocket(socket: WebSocket, data: string, binary: boolean) { - const bytes = decodeBase64(data) - socket.send(binary ? bytes.buffer : new TextDecoder().decode(bytes)) + if (!current.attachment.ready) { + socket.close(1002, "Relay handshake required") + return + } + if (message.type === "tunnel.close") { + this.closeClient(message.id, message.code ?? 1011, message.reason ?? "CodeNomad closed the tunnel") + return + } + const client = this.clientSocket(message.id) + if (!client) return + const attachment = socketAttachment(client) + if (!attachment || attachment.role !== "client") return + const phase = attachment.phase ?? "hello" + if ((phase === "hello" && message.binary) || (phase === "encrypted" && !message.binary)) { + this.closeClient(message.id, 1002, "Invalid encrypted tunnel sequence") + return + } + const limit = phase === "hello" ? REMOTE_CONTROL_MAX_HANDSHAKE_BYTES : MAX_TUNNEL_FRAME_BYTES + if (base64ByteLength(message.data) > limit) { + this.closeClient(message.id, 1009, phase === "hello" ? "Encryption handshake is too large" : "Encrypted tunnel frame is too large") + return + } + try { + const bytes = decodeBase64(message.data) + client.send(message.binary ? bytes.buffer : new TextDecoder().decode(bytes)) + if (phase === "hello") { + attachment.phase = "encrypted" + client.serializeAttachment(attachment) + } + } catch { + this.closeClient(message.id, 1003, "Invalid tunnel frame") + } } - private closeSocket(id: string, reason?: string, code = 1011) { - const pending = this.pendingSockets.get(id) - if (!pending) return - if (!pending.ready) pending.rejectReady(new Error(reason || "CodeNomad WebSocket handshake failed")) - pending.client.close(code, reason?.slice(0, 120) || "Host socket closed") - this.pendingSockets.delete(id) + private onHostClosed(socket: WebSocket): void { + const attachment = socketAttachment(socket) + if (!attachment || attachment.role !== "host" || !attachment.active) return + attachment.active = false + socket.serializeAttachment(attachment) + this.closeAllClients("CodeNomad host disconnected") } - private onHostClosed(socket: WebSocket) { - if (this.hostSocket !== socket) return - this.hostSocket = null - this.hostConnectionId = null - this.hostReady = false - this.failPending("CodeNomad host disconnected") + private closeAllClients(reason: string): void { + for (const socket of this.state.getWebSockets(CLIENT_TAG)) { + const attachment = socketAttachment(socket) + if (attachment?.role === "client") this.closeClient(attachment.id, 1012, reason) + } } - private failPending(reason: string) { - for (const id of this.pendingHttp.keys()) this.failHttp(id, new Error(reason)) - for (const id of this.pendingSockets.keys()) this.closeSocket(id, reason) + private closeClient(id: string, code: number, reason: string): void { + this.clientSocket(id)?.close(safeRelayCloseCode(code), reason.slice(0, 120)) } private isHostConnected(): boolean { - return this.hostReady && this.hostSocket?.readyState === WebSocket.OPEN + return this.hostConnection()?.attachment.ready === true } private sendHost(message: RelayToHostMessage): boolean { - if (!this.isHostConnected()) return false + const host = this.hostConnection() + if (!host?.attachment.ready && message.type !== "ready") return false + if (!host) return false try { - this.hostSocket!.send(JSON.stringify(message)) + host.socket.send(JSON.stringify(message)) return true } catch { return false } } + private hostConnection(): { socket: WebSocket; attachment: HostSocketAttachment } | null { + for (const socket of this.state.getWebSockets(HOST_TAG)) { + const attachment = socketAttachment(socket) + if (socket.readyState === WebSocket.OPEN && attachment?.role === "host" && attachment.active) return { socket, attachment } + } + return null + } + + private clientSocket(id: string): WebSocket | null { + return this.state.getWebSockets(clientTag(id)).find((socket) => socket.readyState === WebSocket.OPEN) ?? null + } + private async authorizeHost(request: Request, allowRegistration = false): Promise { const token = bearerToken(request) - if (!token) return false + if (!token || !HOST_SECRET_PATTERN.test(token)) return false const presented = await tokenHash(token) if (!allowRegistration) return await this.state.storage.get(HOST_SECRET_KEY) === presented return this.state.storage.transaction(async (transaction) => { @@ -438,7 +433,7 @@ export class RemoteControlHost implements DurableObject { private async authorizeDevice(request: Request): Promise { const token = cookieToken(request) - if (!token) return null + if (!token || !RELAY_TOKEN_PATTERN.test(token)) return null const key = `${DEVICE_PREFIX}${await tokenHash(token)}` const device = await this.state.storage.get(key) if (!device || device.expiresAt <= Date.now()) { @@ -451,13 +446,26 @@ export class RemoteControlHost implements DurableObject { } return device } + + private unpairedResponse(): Response { + return Response.json({ error: "Remote device is not paired" }, { + status: 401, + headers: { "Set-Cookie": clearDeviceCookie() }, + }) + } + + private async scheduleExpirationCleanup(expiresAt: number): Promise { + const scheduled = await this.state.storage.getAlarm() + if (scheduled === null || expiresAt < scheduled) await this.state.storage.setAlarm(expiresAt) + } } -function remotePath(request: Request): string { - const url = new URL(request.url) - return `${url.pathname}${url.search}` +function clientTag(id: string): string { + return `${CLIENT_TAG}:${id}` } -function responseMustNotHaveBody(status: number): boolean { - return status === 204 || status === 205 || status === 304 +function socketAttachment(socket: WebSocket): SocketAttachment | null { + const value = socket.deserializeAttachment() as Partial | null + if (!value || (value.role !== "host" && value.role !== "client")) return null + return value as SocketAttachment } diff --git a/packages/cloudflare/src/remote-control/relay-messages.ts b/packages/cloudflare/src/remote-control/relay-messages.ts new file mode 100644 index 000000000..b56ca9317 --- /dev/null +++ b/packages/cloudflare/src/remote-control/relay-messages.ts @@ -0,0 +1,56 @@ +import type { HostToRelayMessage } from "@codenomad/remote-control-protocol" + +export async function readPairingInput(request: Request, maxBytes: number): Promise<{ token?: unknown; name?: unknown } | null> { + if (!request.body) return null + const reader = request.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > maxBytes) { + await reader.cancel() + return null + } + chunks.push(value) + } + const body = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + try { + const value = JSON.parse(new TextDecoder().decode(body)) as unknown + return typeof value === "object" && value !== null ? value : null + } catch { + return null + } +} + +export function parseHostMessage(value: string): HostToRelayMessage | null { + try { + const message = JSON.parse(value) as Partial + if (message.type === "ready" && typeof message.protocol === "number") return message as HostToRelayMessage + if (message.type === "tunnel.close" && typeof message.id === "string") return message as HostToRelayMessage + if (message.type === "tunnel.message" && typeof message.id === "string" && typeof message.data === "string" && typeof message.binary === "boolean") { + return message as HostToRelayMessage + } + return null + } catch { + return null + } +} + +export function base64ByteLength(value: string): number { + if (!value) return 0 + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0 + return Math.floor(value.length * 3 / 4) - padding +} + +export function safeRelayCloseCode(value: number): number { + if (value === 1000 || value === 1001 || value === 1002 || value === 1003 + || (value >= 1007 && value <= 1014) || (value >= 3000 && value <= 4999)) return value + return 1011 +} diff --git a/packages/cloudflare/src/remote-control/security.test.ts b/packages/cloudflare/src/remote-control/security.test.ts index 0d87f02ff..5d0ea3f99 100644 --- a/packages/cloudflare/src/remote-control/security.test.ts +++ b/packages/cloudflare/src/remote-control/security.test.ts @@ -13,6 +13,7 @@ test("device credentials use secure host-scoped cookies", () => { assert.match(cookie, /HttpOnly; Secure; SameSite=Strict; Path=\/; Max-Age=60/) assert.equal(cookieToken(new Request("https://host.relay.example", { headers: { Cookie: cookie } })), "device token") assert.match(clearDeviceCookie(), /Max-Age=0/) + assert.equal(cookieToken(new Request("https://host.relay.example", { headers: { Cookie: `${DEVICE_COOKIE}=%GG` } })), null) }) test("stored credentials are deterministic hashes rather than raw tokens", async () => { diff --git a/packages/cloudflare/src/remote-control/security.ts b/packages/cloudflare/src/remote-control/security.ts index 2f3aaf99c..7cc3d50ca 100644 --- a/packages/cloudflare/src/remote-control/security.ts +++ b/packages/cloudflare/src/remote-control/security.ts @@ -2,6 +2,8 @@ const encoder = new TextEncoder() export const HOST_ID_PATTERN = /^[a-f0-9]{32}$/ export const DEVICE_COOKIE = "codenomad_remote_device" +export const RELAY_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/ +export const HOST_SECRET_PATTERN = /^[A-Za-z0-9_-]{40,128}$/ export function randomToken(byteLength = 32): string { const bytes = crypto.getRandomValues(new Uint8Array(byteLength)) @@ -25,7 +27,13 @@ export function cookieToken(request: Request): string | null { const cookies = request.headers.get("cookie") ?? "" for (const entry of cookies.split(";")) { const [name, ...parts] = entry.trim().split("=") - if (name === DEVICE_COOKIE) return decodeURIComponent(parts.join("=")) + if (name === DEVICE_COOKIE) { + try { + return decodeURIComponent(parts.join("=")) + } catch { + return null + } + } } return null } diff --git a/packages/cloudflare/wrangler.toml b/packages/cloudflare/wrangler.toml index 83be69378..143a2fee0 100644 --- a/packages/cloudflare/wrangler.toml +++ b/packages/cloudflare/wrangler.toml @@ -31,3 +31,4 @@ new_sqlite_classes = ["RemoteControlHost"] directory = "./dist" binding = "ASSETS" not_found_handling = "404-page" +run_worker_first = true diff --git a/packages/remote-control-protocol/src/crypto.ts b/packages/remote-control-protocol/src/crypto.ts new file mode 100644 index 000000000..3254c66d4 --- /dev/null +++ b/packages/remote-control-protocol/src/crypto.ts @@ -0,0 +1,249 @@ +import { REMOTE_CONTROL_MAX_PLAINTEXT_BYTES, REMOTE_CONTROL_PROTOCOL_VERSION, decodeBase64, encodeBase64 } from "./messages" + +const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" } as const +const FRAME_VERSION = 1 +const IV_PREFIX_BYTES = 4 +const IV_COUNTER_BYTES = 8 +const MAX_COUNTER = (1n << BigInt(IV_COUNTER_BYTES * 8)) - 1n +const IV_BYTES = IV_PREFIX_BYTES + IV_COUNTER_BYTES +const TAG_BYTES = 16 +const SESSION_KEY_BYTES = 32 +const HANDSHAKE_NONCE_BYTES = 16 +const KEY_INFO = new TextEncoder().encode("codenomad-remote-control-e2ee-v2") +const FRAME_AAD = new TextEncoder().encode("codenomad-remote-control-frame-v2") +const READY_PROOF = new TextEncoder().encode("codenomad-remote-control-ready-v2") + +export interface EncryptedChannel { + encrypt(plaintext: Uint8Array): Promise + decrypt(frame: Uint8Array): Promise +} + +interface DirectionalKeys { + clientToHost: CryptoKey + hostToClient: CryptoKey +} + +interface HelloMessage { + type: "e2ee.hello" + protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION + publicKey: JsonWebKey + nonce: string +} + +interface ReadyMessage { + type: "e2ee.ready" + protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION + nonce: string + proof: string +} + +export interface ClientHandshake { + hello: string + accept(message: string): Promise +} + +export interface HostHandshake { + accept(message: string): Promise<{ ready: string; channel: EncryptedChannel }> +} + +export async function createClientHandshake(hostPublicJwk: JsonWebKey): Promise { + const hostPublicKey = await importPublicKey(hostPublicJwk) + const ephemeral = await crypto.subtle.generateKey(ECDH_ALGORITHM, true, ["deriveBits"]) as CryptoKeyPair + const nonce = crypto.getRandomValues(new Uint8Array(HANDSHAKE_NONCE_BYTES)) + const publicKey = await crypto.subtle.exportKey("jwk", ephemeral.publicKey) + const hello: HelloMessage = { + type: "e2ee.hello", + protocol: REMOTE_CONTROL_PROTOCOL_VERSION, + publicKey: publicJwk(publicKey), + nonce: encodeBase64(nonce), + } + let accepted = false + return { + hello: JSON.stringify(hello), + async accept(message: string) { + if (accepted) throw new Error("Remote Control handshake was already accepted") + const ready = parseReady(message) + if (!ready) throw new Error("Invalid Remote Control host handshake") + const hostNonce = decodeNonce(ready.nonce) + const keys = await deriveKeys(ephemeral.privateKey, hostPublicKey, nonce, hostNonce) + const channel = createChannel(keys.clientToHost, keys.hostToClient) + const proof = await channel.decrypt(decodeBase64(ready.proof)) + if (!equalBytes(proof, READY_PROOF)) throw new Error("Remote Control host handshake authentication failed") + accepted = true + return channel + }, + } +} + +export async function createHostHandshake(hostPrivateJwk: JsonWebKey): Promise { + const hostPrivateKey = await importPrivateKey(hostPrivateJwk) + let accepted = false + return { + async accept(message: string) { + if (accepted) throw new Error("Remote Control handshake was already accepted") + const hello = parseHello(message) + if (!hello) throw new Error("Invalid Remote Control client handshake") + const clientNonce = decodeNonce(hello.nonce) + const hostNonce = crypto.getRandomValues(new Uint8Array(HANDSHAKE_NONCE_BYTES)) + const clientPublicKey = await importPublicKey(hello.publicKey) + const keys = await deriveKeys(hostPrivateKey, clientPublicKey, clientNonce, hostNonce) + const channel = createChannel(keys.hostToClient, keys.clientToHost) + const proof = await channel.encrypt(READY_PROOF) + accepted = true + const ready: ReadyMessage = { + type: "e2ee.ready", + protocol: REMOTE_CONTROL_PROTOCOL_VERSION, + nonce: encodeBase64(hostNonce), + proof: encodeBase64(proof), + } + return { + ready: JSON.stringify(ready), + channel, + } + }, + } +} + +function createChannel(encryptionKey: CryptoKey, decryptionKey: CryptoKey): EncryptedChannel { + const prefix = crypto.getRandomValues(new Uint8Array(IV_PREFIX_BYTES)) + let sendCounter = 0n + let receiveCounter = 0n + return { + async encrypt(plaintext) { + if (plaintext.byteLength > REMOTE_CONTROL_MAX_PLAINTEXT_BYTES) throw new Error("Remote Control encrypted frame is too large") + if (sendCounter >= MAX_COUNTER) throw new Error("Remote Control encrypted channel counter is exhausted") + sendCounter += 1n + const iv = new Uint8Array(IV_BYTES) + iv.set(prefix) + writeCounter(iv, IV_PREFIX_BYTES, sendCounter) + const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ + name: "AES-GCM", + iv, + additionalData: FRAME_AAD, + }, encryptionKey, arrayBuffer(plaintext))) + const frame = new Uint8Array(1 + IV_BYTES + ciphertext.byteLength) + frame[0] = FRAME_VERSION + frame.set(iv, 1) + frame.set(ciphertext, 1 + IV_BYTES) + return frame + }, + async decrypt(frame) { + if (frame.byteLength < 1 + IV_BYTES + TAG_BYTES || frame.byteLength > 1 + IV_BYTES + TAG_BYTES + REMOTE_CONTROL_MAX_PLAINTEXT_BYTES) { + throw new Error("Invalid Remote Control encrypted frame size") + } + if (frame[0] !== FRAME_VERSION) throw new Error("Unsupported Remote Control encrypted frame") + const iv = frame.slice(1, 1 + IV_BYTES) + const counter = readCounter(iv, IV_PREFIX_BYTES) + if (counter <= receiveCounter) throw new Error("Remote Control encrypted frame was replayed") + let plaintext: ArrayBuffer + try { + plaintext = await crypto.subtle.decrypt({ + name: "AES-GCM", + iv, + additionalData: FRAME_AAD, + }, decryptionKey, arrayBuffer(frame.slice(1 + IV_BYTES))) + } catch { + throw new Error("Remote Control encrypted frame authentication failed") + } + receiveCounter = counter + return new Uint8Array(plaintext) + }, + } +} + +async function deriveKeys( + privateKey: CryptoKey, + publicKey: CryptoKey, + clientNonce: Uint8Array, + hostNonce: Uint8Array, +): Promise { + const shared = await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256) + const material = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]) + const salt = new Uint8Array(clientNonce.byteLength + hostNonce.byteLength) + salt.set(clientNonce) + salt.set(hostNonce, clientNonce.byteLength) + const bits = new Uint8Array(await crypto.subtle.deriveBits({ + name: "HKDF", + hash: "SHA-256", + salt: arrayBuffer(salt), + info: KEY_INFO, + }, material, SESSION_KEY_BYTES * 2 * 8)) + return { + clientToHost: await importAesKey(bits.slice(0, SESSION_KEY_BYTES)), + hostToClient: await importAesKey(bits.slice(SESSION_KEY_BYTES)), + } +} + +function importAesKey(bytes: Uint8Array): Promise { + return crypto.subtle.importKey("raw", arrayBuffer(bytes), "AES-GCM", false, ["encrypt", "decrypt"]) +} + +function importPublicKey(jwk: JsonWebKey): Promise { + const normalized = publicJwk(jwk) + return crypto.subtle.importKey("jwk", normalized, ECDH_ALGORITHM, false, []) +} + +function importPrivateKey(jwk: JsonWebKey): Promise { + if (typeof jwk.d !== "string") return Promise.reject(new Error("Invalid Remote Control private key")) + return crypto.subtle.importKey("jwk", { ...publicJwk(jwk), d: jwk.d }, ECDH_ALGORITHM, false, ["deriveBits"]) +} + +function publicJwk(jwk: JsonWebKey): JsonWebKey { + if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || typeof jwk.y !== "string") { + throw new Error("Invalid Remote Control public key") + } + return { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y, ext: true } +} + +function parseHello(value: string): HelloMessage | null { + const parsed = parseObject(value) + if (parsed?.type !== "e2ee.hello" || parsed.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) return null + if (typeof parsed.publicKey !== "object" || parsed.publicKey === null || typeof parsed.nonce !== "string") return null + return parsed as unknown as HelloMessage +} + +function parseReady(value: string): ReadyMessage | null { + const parsed = parseObject(value) + if (parsed?.type !== "e2ee.ready" || parsed.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) return null + if (typeof parsed.nonce !== "string" || typeof parsed.proof !== "string") return null + return parsed as unknown as ReadyMessage +} + +function parseObject(value: string): Record | null { + try { + const parsed = JSON.parse(value) + return typeof parsed === "object" && parsed !== null ? parsed as Record : null + } catch { + return null + } +} + +function writeCounter(target: Uint8Array, offset: number, value: bigint): void { + for (let index = IV_COUNTER_BYTES - 1; index >= 0; index -= 1) { + target[offset + index] = Number(value & 0xffn) + value >>= 8n + } +} + +function readCounter(source: Uint8Array, offset: number): bigint { + let value = 0n + for (let index = 0; index < IV_COUNTER_BYTES; index += 1) value = (value << 8n) | BigInt(source[offset + index]) + return value +} + +function arrayBuffer(bytes: Uint8Array): ArrayBuffer { + return (bytes.buffer as ArrayBuffer).slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) +} + +function decodeNonce(value: string): Uint8Array { + const nonce = decodeBase64(value) + if (nonce.byteLength !== HANDSHAKE_NONCE_BYTES) throw new Error("Invalid Remote Control handshake nonce") + return nonce +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false + let difference = 0 + for (let index = 0; index < left.byteLength; index += 1) difference |= left[index] ^ right[index] + return difference === 0 +} diff --git a/packages/remote-control-protocol/src/index.test.ts b/packages/remote-control-protocol/src/index.test.ts index fe875ea73..235066173 100644 --- a/packages/remote-control-protocol/src/index.test.ts +++ b/packages/remote-control-protocol/src/index.test.ts @@ -1,8 +1,66 @@ import assert from "node:assert/strict" import test from "node:test" -import { decodeBase64, encodeBase64 } from "./index" +import { createClientHandshake, createHostHandshake, decodeBase64, encodeBase64 } from "./index" test("binary relay payloads round-trip without truncation", () => { const input = Uint8Array.from({ length: 100_000 }, (_, index) => index % 251) assert.deepEqual(decodeBase64(encodeBase64(input)), input) }) + +test("client and host establish authenticated directional encryption", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const publicKey = await crypto.subtle.exportKey("jwk", pair.publicKey) + const privateKey = await crypto.subtle.exportKey("jwk", pair.privateKey) + const client = await createClientHandshake(publicKey) + const host = await createHostHandshake(privateKey) + const accepted = await host.accept(client.hello) + const clientChannel = await client.accept(accepted.ready) + const plaintext = new TextEncoder().encode("opaque remote payload") + + const toHost = await clientChannel.encrypt(plaintext) + assert.notDeepEqual(toHost, plaintext) + assert.deepEqual(await accepted.channel.decrypt(toHost), plaintext) + + const toClient = await accepted.channel.encrypt(plaintext) + assert.deepEqual(await clientChannel.decrypt(toClient), plaintext) + await assert.rejects(() => clientChannel.decrypt(toClient), /replayed/) +}) + +test("encrypted frames fail closed after tampering", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const client = await createClientHandshake(await crypto.subtle.exportKey("jwk", pair.publicKey)) + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", pair.privateKey)) + const accepted = await host.accept(client.hello) + const clientChannel = await client.accept(accepted.ready) + const frame = await clientChannel.encrypt(new TextEncoder().encode("secret")) + frame[frame.length - 1] ^= 1 + await assert.rejects(() => accepted.channel.decrypt(frame), /authentication failed/) +}) + +test("host challenge prevents encrypted requests from being replayed into a new tunnel", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const publicKey = await crypto.subtle.exportKey("jwk", pair.publicKey) + const privateKey = await crypto.subtle.exportKey("jwk", pair.privateKey) + const client = await createClientHandshake(publicKey) + const firstHost = await createHostHandshake(privateKey) + const firstAccepted = await firstHost.accept(client.hello) + const clientChannel = await client.accept(firstAccepted.ready) + const captured = await clientChannel.encrypt(new TextEncoder().encode("state-changing request")) + + const secondHost = await createHostHandshake(privateKey) + const secondAccepted = await secondHost.accept(client.hello) + await assert.rejects(() => secondAccepted.channel.decrypt(captured), /authentication failed/) +}) + +test("client authenticates the host before establishing a channel", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const client = await createClientHandshake(await crypto.subtle.exportKey("jwk", pair.publicKey)) + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", pair.privateKey)) + const accepted = await host.accept(client.hello) + const ready = JSON.parse(accepted.ready) as { proof: string } + const proof = decodeBase64(ready.proof) + proof[proof.length - 1] ^= 1 + ready.proof = encodeBase64(proof) + + await assert.rejects(() => client.accept(JSON.stringify(ready)), /authentication failed/) +}) diff --git a/packages/remote-control-protocol/src/index.ts b/packages/remote-control-protocol/src/index.ts index 7d1e168ef..4bca6070f 100644 --- a/packages/remote-control-protocol/src/index.ts +++ b/packages/remote-control-protocol/src/index.ts @@ -1,87 +1,2 @@ -export const REMOTE_CONTROL_PROTOCOL_VERSION = 1 as const - -export type HeaderEntries = Array<[string, string]> - -export type RelayToHostMessage = - | { type: "ready"; protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION } - | { - type: "http.request" - id: string - method: string - path: string - headers: HeaderEntries - body?: string - } - | { type: "http.cancel"; id: string } - | { - type: "socket.open" - id: string - path: string - headers: HeaderEntries - protocols: string[] - } - | { type: "socket.message"; id: string; data: string; binary: boolean } - | { type: "socket.close"; id: string; code?: number; reason?: string } - | { type: "ping"; at: number } - -export type HostToRelayMessage = - | { type: "ready"; protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION } - | { type: "pong"; at: number } - | { - type: "http.start" - id: string - status: number - headers: HeaderEntries - } - | { type: "http.chunk"; id: string; data: string } - | { type: "http.end"; id: string } - | { type: "http.error"; id: string; message: string } - | { type: "socket.ready"; id: string; protocol?: string } - | { type: "socket.message"; id: string; data: string; binary: boolean } - | { type: "socket.close"; id: string; code?: number; reason?: string } - | { type: "socket.error"; id: string; message: string } - -export interface RemoteControlStatus { - manageable: boolean - enabled: boolean - state: "stopped" | "connecting" | "connected" | "reconnecting" | "error" - hostId: string - relayUrl: string - remoteUrl: string - pairedDevices: number - lastConnectedAt?: string - error?: string -} - -export interface RemoteControlPairing { - url: string - expiresAt: string -} - -export interface RemoteControlDevice { - id: string - name: string - createdAt: string - lastSeenAt: string -} - -export interface RemoteControlStartResponse { - status: RemoteControlStatus - pairing: RemoteControlPairing -} - -export function encodeBase64(bytes: Uint8Array): string { - let binary = "" - const chunkSize = 0x8000 - for (let offset = 0; offset < bytes.length; offset += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) - } - return btoa(binary) -} - -export function decodeBase64(value: string): Uint8Array { - const binary = atob(value) - const bytes = new Uint8Array(binary.length) - for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) - return bytes -} +export * from "./crypto" +export * from "./messages" diff --git a/packages/remote-control-protocol/src/messages.ts b/packages/remote-control-protocol/src/messages.ts new file mode 100644 index 000000000..2f0542cd6 --- /dev/null +++ b/packages/remote-control-protocol/src/messages.ts @@ -0,0 +1,99 @@ +export const REMOTE_CONTROL_PROTOCOL_VERSION = 2 as const +export const REMOTE_CONTROL_HEARTBEAT_REQUEST = "codenomad.remote-control.ping.v2" +export const REMOTE_CONTROL_HEARTBEAT_RESPONSE = "codenomad.remote-control.pong.v2" +export const REMOTE_CONTROL_MAX_HANDSHAKE_BYTES = 4 * 1024 +export const REMOTE_CONTROL_MAX_HTTP_BODY_BYTES = 12 * 1024 * 1024 +export const REMOTE_CONTROL_MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024 + +export type HeaderEntries = Array<[string, string]> + +export type RelayToHostMessage = + | { type: "ready"; protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION } + | { type: "tunnel.open"; id: string } + | { type: "tunnel.message"; id: string; data: string; binary: boolean } + | { type: "tunnel.close"; id: string; code?: number; reason?: string } + +export type HostToRelayMessage = + | { type: "ready"; protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION } + | { type: "tunnel.message"; id: string; data: string; binary: boolean } + | { type: "tunnel.close"; id: string; code?: number; reason?: string } + +export type ClientToHostMessage = + | { + type: "http.request" + id: string + method: string + path: string + headers: HeaderEntries + body?: string + } + | { type: "http.cancel"; id: string } + | { + type: "socket.open" + id: string + path: string + headers: HeaderEntries + protocols: string[] + } + | { type: "socket.message"; id: string; data: string; binary: boolean } + | { type: "socket.close"; id: string; code?: number; reason?: string } + +export type HostToClientMessage = + | { + type: "http.start" + id: string + status: number + headers: HeaderEntries + } + | { type: "http.chunk"; id: string; data: string } + | { type: "http.end"; id: string } + | { type: "http.error"; id: string; message: string } + | { type: "socket.ready"; id: string; protocol?: string } + | { type: "socket.message"; id: string; data: string; binary: boolean } + | { type: "socket.close"; id: string; code?: number; reason?: string } + | { type: "socket.error"; id: string; message: string } + +export interface RemoteControlStatus { + manageable: boolean + enabled: boolean + state: "stopped" | "connecting" | "connected" | "reconnecting" | "error" + hostId: string + relayUrl: string + remoteUrl: string + pairedDevices: number + lastConnectedAt?: string + error?: string +} + +export interface RemoteControlPairing { + url: string + expiresAt: string +} + +export interface RemoteControlDevice { + id: string + name: string + createdAt: string + lastSeenAt: string +} + +export interface RemoteControlStartResponse { + status: RemoteControlStatus + pairing: RemoteControlPairing +} + +export function encodeBase64(bytes: Uint8Array): string { + let binary = "" + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} + +export function decodeBase64(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} diff --git a/packages/server/README.md b/packages/server/README.md index 5ffdbfaae..fe88b1939 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -97,7 +97,7 @@ You can configure the server using flags or environment variables: | `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true`) | | `--ui-manifest-url ` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | -Remote Control uses `https://remote.codenomad.neuralnomads.ai` by default. Set `CODENOMAD_REMOTE_CONTROL_RELAY_URL` to use another compatible relay. +Remote Control uses `https://remote.codenomad.neuralnomads.ai` by default. Set `CODENOMAD_REMOTE_CONTROL_RELAY_URL` to use another compatible relay. The shared relay uses Cloudflare Durable Objects WebSocket Hibernation: idle host connections remain reachable without keeping an object active, and application heartbeats are answered without waking it. Protocol v2 encrypts application traffic end to end with directional AES-256-GCM keys derived from an ephemeral browser key, the pinned persistent host key, and fresh per-tunnel challenges; the relay routes ciphertext without receiving the decryption keys. ### Dev Releases (Advanced) @@ -131,7 +131,7 @@ codenomad --https=true --http=true ### Remote Control Network Model -Both HTTP and HTTPS listeners bind to `127.0.0.1`. Remote Control never forwards its device cookie to the local server: the outbound connector strips remote credentials and injects a dedicated internal CodeNomad session. OpenCode remains behind CodeNomad's existing authorization, workspace, Git, Yolo, and proxy boundaries. +Both HTTP and HTTPS listeners bind to `127.0.0.1`. Remote Control opens only an outbound host WebSocket and requires no inbound port. It never forwards its device cookie or remote authorization headers to the local server: after host-side decryption, the connector injects a dedicated internal CodeNomad session. OpenCode remains behind CodeNomad's existing authorization, workspace, Git, Yolo, and proxy boundaries. ### Self-Signed Certificates @@ -215,7 +215,7 @@ CodeNomad can be installed as a PWA from a supported browser, including from a p - **CodeNomad instance data**: `~/.config/codenomad/instances/` - **OpenCode V2 sessions, messages, and service registration**: OpenCode's platform-default global locations. - **Desktop restore state**: `~/.codenomad/client-state/v2/` -- **Remote Control host identity**: `~/.config/codenomad/remote-control.json` (random host ID and secret; keep private) +- **Remote Control host identity**: `~/.config/codenomad/remote-control.json` (random host ID, relay secret, and P-256 private key; keep private) CodeNomad owns no private OpenCode port, database, service registration, or daemon PID. Configured allowed `server.environmentVariables` and the current `NODE_EXTRA_CA_CERTS` apply only when CodeNomad starts a missing daemon. Existing daemons are unchanged; legacy `OPENCODE_DB` and `XDG_STATE_HOME` ownership variables are ignored. WSL lifecycle commands run inside Linux and never inspect or signal Linux PIDs from Windows. diff --git a/packages/server/src/remote-control/connector-protocol.ts b/packages/server/src/remote-control/connector-protocol.ts new file mode 100644 index 000000000..4460e7829 --- /dev/null +++ b/packages/server/src/remote-control/connector-protocol.ts @@ -0,0 +1,120 @@ +import type { ClientToHostMessage, HeaderEntries, RelayToHostMessage } from "@codenomad/remote-control-protocol" + +const RESPONSE_HEADER_BLOCKLIST = new Set(["connection", "content-encoding", "content-length", "set-cookie", "transfer-encoding", "upgrade"]) +const REQUEST_HEADER_BLOCKLIST = new Set([ + "authorization", + "connection", + "cookie", + "forwarded", + "host", + "origin", + "proxy-authorization", + "referer", + "transfer-encoding", + "upgrade", +]) + +export const ALLOWED_REMOTE_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]) +const ALLOWED_REMOTE_PATH_PREFIXES = ["/api/", "/workspaces/"] + +export function relaySocketUrl(relayUrl: string, hostId: string): URL { + const url = new URL(`/api/hosts/${hostId}/connect`, normalizedRelayUrl(relayUrl)) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + return url +} + +export function normalizedRelayUrl(value: string): URL { + const url = new URL(value) + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) { + throw new Error("Remote Control relay must use HTTPS") + } + url.pathname = "/" + url.search = "" + url.hash = "" + return url +} + +export function allowedRemotePath(path: string): boolean { + return ALLOWED_REMOTE_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)) +} + +export function localHeaders(entries: HeaderEntries, cookie: string): Headers { + const headers = new Headers() + for (const [name, value] of entries) if (!blockedRequestHeader(name)) headers.append(name, value) + headers.set("Cookie", cookie) + headers.set("X-CodeNomad-Remote-Control", "1") + return headers +} + +export function responseHeaders(headers: Headers): HeaderEntries { + const entries: HeaderEntries = [] + headers.forEach((value, name) => { + if (!RESPONSE_HEADER_BLOCKLIST.has(name.toLowerCase())) entries.push([name, value]) + }) + return entries +} + +export function parseRelayMessage(value: string): RelayToHostMessage | null { + try { + const message = JSON.parse(value) as Partial + if (message.type === "ready" && typeof message.protocol === "number") return message as RelayToHostMessage + if (message.type === "tunnel.open" && typeof message.id === "string") return message as RelayToHostMessage + if (message.type === "tunnel.close" && typeof message.id === "string") return message as RelayToHostMessage + if (message.type === "tunnel.message" && typeof message.id === "string" && typeof message.data === "string" && typeof message.binary === "boolean") { + return message as RelayToHostMessage + } + return null + } catch { + return null + } +} + +export function parseClientMessage(value: string): ClientToHostMessage | null { + try { + const message = JSON.parse(value) as Partial + if (typeof message.id !== "string" || !message.id || typeof message.type !== "string") return null + if (message.type === "http.cancel") return message as ClientToHostMessage + if (message.type === "socket.close") return message as ClientToHostMessage + if (message.type === "socket.message" && typeof message.data === "string" && typeof message.binary === "boolean") return message as ClientToHostMessage + if (message.type === "http.request" && typeof message.method === "string" && typeof message.path === "string" && validHeaders(message.headers)) { + if (message.body !== undefined && typeof message.body !== "string") return null + return message as ClientToHostMessage + } + if (message.type === "socket.open" && typeof message.path === "string" && validHeaders(message.headers) && Array.isArray(message.protocols)) { + if (!message.protocols.every((protocol) => typeof protocol === "string")) return null + return message as ClientToHostMessage + } + return null + } catch { + return null + } +} + +export function base64ByteLength(value: string): number { + if (!value) return 0 + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0 + return Math.floor(value.length * 3 / 4) - padding +} + +export function validCloseCode(value: number | undefined): value is number { + return value === 1000 || (typeof value === "number" && value >= 3000 && value <= 4999) +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase() + return normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.startsWith("127.") +} + +function validHeaders(value: unknown): value is HeaderEntries { + return Array.isArray(value) && value.length <= 256 + && value.every((entry) => Array.isArray(entry) && entry.length === 2 && entry.every((item) => typeof item === "string")) +} + +function blockedRequestHeader(name: string): boolean { + const normalized = name.toLowerCase() + return REQUEST_HEADER_BLOCKLIST.has(normalized) + || normalized.startsWith("proxy-") + || normalized.startsWith("sec-websocket-") + || normalized.startsWith("x-codenomad-") + || normalized.startsWith("x-forwarded-") +} diff --git a/packages/server/src/remote-control/connector.test.ts b/packages/server/src/remote-control/connector.test.ts index f1adae33b..74db9a46b 100644 --- a/packages/server/src/remote-control/connector.test.ts +++ b/packages/server/src/remote-control/connector.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import test from "node:test" import { normalizedRelayUrl } from "./connector" +import { allowedRemotePath, localHeaders } from "./connector-protocol" test("Remote Control relays require HTTPS except for loopback development", () => { assert.equal(normalizedRelayUrl("https://relay.example/path?ignored=1").href, "https://relay.example/") @@ -9,3 +10,28 @@ test("Remote Control relays require HTTPS except for loopback development", () = assert.throws(() => normalizedRelayUrl("http://relay.example"), /must use HTTPS/) assert.throws(() => normalizedRelayUrl("file:///relay"), /must use HTTPS/) }) + +test("Remote Control replaces remote credentials and forwarding metadata with host-local identity", () => { + const headers = localHeaders([ + ["authorization", "Bearer remote"], + ["cookie", "remote=session"], + ["origin", "https://remote.example"], + ["x-forwarded-host", "attacker.example"], + ["x-codenomad-remote-control", "0"], + ["content-type", "application/json"], + ], "local=session") + assert.equal(headers.get("authorization"), null) + assert.equal(headers.get("origin"), null) + assert.equal(headers.get("x-forwarded-host"), null) + assert.equal(headers.get("cookie"), "local=session") + assert.equal(headers.get("x-codenomad-remote-control"), "1") + assert.equal(headers.get("content-type"), "application/json") +}) + +test("Remote Control reaches only API and workspace namespaces", () => { + assert.equal(allowedRemotePath("/api/events"), true) + assert.equal(allowedRemotePath("/workspaces/example/instance/api/session"), true) + assert.equal(allowedRemotePath("/api"), false) + assert.equal(allowedRemotePath("/assets/app.js"), false) + assert.equal(allowedRemotePath("//attacker.example/api/events"), false) +}) diff --git a/packages/server/src/remote-control/connector.ts b/packages/server/src/remote-control/connector.ts index 165021ce3..47039c4ce 100644 --- a/packages/server/src/remote-control/connector.ts +++ b/packages/server/src/remote-control/connector.ts @@ -1,20 +1,43 @@ import { + REMOTE_CONTROL_HEARTBEAT_REQUEST, + REMOTE_CONTROL_HEARTBEAT_RESPONSE, + REMOTE_CONTROL_MAX_HANDSHAKE_BYTES, + REMOTE_CONTROL_MAX_HTTP_BODY_BYTES, REMOTE_CONTROL_PROTOCOL_VERSION, + createHostHandshake, decodeBase64, encodeBase64, - type HeaderEntries, + type ClientToHostMessage, + type EncryptedChannel, + type HostToClientMessage, type HostToRelayMessage, - type RelayToHostMessage, } from "@codenomad/remote-control-protocol" import { Agent, fetch, WebSocket } from "undici" import type { Logger } from "../logger" +import { + ALLOWED_REMOTE_METHODS, + allowedRemotePath, + base64ByteLength, + localHeaders, + normalizedRelayUrl, + parseClientMessage, + parseRelayMessage, + relaySocketUrl, + responseHeaders, + validCloseCode, +} from "./connector-protocol" + +export { normalizedRelayUrl } from "./connector-protocol" const INITIAL_RECONNECT_MS = 1_000 const MAX_RECONNECT_MS = 30_000 -const MAX_QUEUED_SOCKET_MESSAGES = 256 +const MAX_ACTIVE_HTTP_REQUESTS = 32 +const MAX_LOCAL_SOCKETS = 16 +const MAX_QUEUED_SOCKET_MESSAGES = 64 +const MAX_QUEUED_SOCKET_BYTES = 1024 * 1024 const RELAY_HANDSHAKE_TIMEOUT_MS = 15_000 -const RESPONSE_HEADER_BLOCKLIST = new Set(["connection", "content-encoding", "content-length", "set-cookie", "transfer-encoding", "upgrade"]) -const REQUEST_HEADER_BLOCKLIST = new Set(["authorization", "connection", "cookie", "host", "proxy-authorization", "transfer-encoding", "upgrade"]) +const HEARTBEAT_INTERVAL_MS = 30_000 +const HEARTBEAT_TIMEOUT_MS = 70_000 export type ConnectorState = "stopped" | "connecting" | "connected" | "reconnecting" | "error" @@ -22,22 +45,33 @@ interface ConnectorOptions { relayUrl: string hostId: string secret: string + encryptionPrivateKey: JsonWebKey localUrl: () => string localCookie: () => string logger: Logger onState: (state: ConnectorState, error?: string) => void } +interface TunnelState { + handshake: ReturnType + channel?: EncryptedChannel + receiveQueue: Promise + sendQueue: Promise + httpRequests: Map + localSockets: Map> + localSocketQueues: Map> +} + export class RemoteControlConnector { private socket: InstanceType | null = null private reconnectTimer: NodeJS.Timeout | null = null private handshakeTimer: NodeJS.Timeout | null = null + private heartbeatTimer: NodeJS.Timeout | null = null + private lastHeartbeatAt = 0 private ready = false private desired = false private reconnectDelay = INITIAL_RECONNECT_MS - private readonly httpRequests = new Map() - private readonly localSockets = new Map>() - private readonly localSocketQueues = new Map>() + private readonly tunnels = new Map() private readonly localDispatcher = new Agent({ connect: { rejectUnauthorized: false } }) constructor(private readonly options: ConnectorOptions) {} @@ -55,10 +89,11 @@ export class RemoteControlConnector { this.reconnectTimer = null if (this.handshakeTimer) clearTimeout(this.handshakeTimer) this.handshakeTimer = null + this.stopHeartbeat() this.socket?.close(1000, "Remote Control stopped") this.socket = null this.ready = false - this.abortInflight() + this.closeAllTunnels() this.options.onState("stopped") } @@ -71,11 +106,10 @@ export class RemoteControlConnector { return this.ready && this.socket?.readyState === WebSocket.OPEN } - private connect(state: "connecting" | "reconnecting") { + private connect(state: "connecting" | "reconnecting"): void { if (!this.desired || this.socket) return this.options.onState(state) - const url = relaySocketUrl(this.options.relayUrl, this.options.hostId) - const socket = new WebSocket(url, { + const socket = new WebSocket(relaySocketUrl(this.options.relayUrl, this.options.hostId), { headers: { Authorization: `Bearer ${this.options.secret}` }, }) this.socket = socket @@ -83,24 +117,23 @@ export class RemoteControlConnector { socket.addEventListener("open", () => { if (this.socket !== socket) return this.reconnectDelay = INITIAL_RECONNECT_MS - this.send({ type: "ready", protocol: REMOTE_CONTROL_PROTOCOL_VERSION }) + this.sendRelay({ type: "ready", protocol: REMOTE_CONTROL_PROTOCOL_VERSION }) this.handshakeTimer = setTimeout(() => socket.close(1002, "Remote Control relay handshake timed out"), RELAY_HANDSHAKE_TIMEOUT_MS) this.handshakeTimer.unref() }) - socket.addEventListener("message", (event) => void this.onMessage(event.data).catch((error) => { - this.options.logger.warn({ err: error }, "Remote Control message failed") - })) + socket.addEventListener("message", (event) => this.onRelayMessage(socket, event.data)) socket.addEventListener("close", () => this.onClosed(socket)) socket.addEventListener("error", () => this.onClosed(socket, "Unable to connect to the Remote Control relay")) } - private onClosed(socket: InstanceType, error?: string) { + private onClosed(socket: InstanceType, error?: string): void { if (this.socket !== socket) return this.socket = null this.ready = false if (this.handshakeTimer) clearTimeout(this.handshakeTimer) this.handshakeTimer = null - this.abortInflight() + this.stopHeartbeat() + this.closeAllTunnels() if (!this.desired) { this.options.onState("stopped") return @@ -116,10 +149,19 @@ export class RemoteControlConnector { this.reconnectTimer.unref() } - private async onMessage(data: unknown) { + private onRelayMessage(socket: InstanceType, data: unknown): void { + if (this.socket !== socket) return const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? new TextDecoder().decode(data) : "" if (!text) return - const message = JSON.parse(text) as RelayToHostMessage + if (text === REMOTE_CONTROL_HEARTBEAT_RESPONSE) { + this.lastHeartbeatAt = Date.now() + return + } + const message = parseRelayMessage(text) + if (!message) { + this.socket?.close(1003, "Invalid Remote Control relay message") + return + } if (message.type === "ready") { if (message.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) { this.socket?.close(1002, "Unsupported Remote Control protocol") @@ -128,32 +170,96 @@ export class RemoteControlConnector { if (this.handshakeTimer) clearTimeout(this.handshakeTimer) this.handshakeTimer = null this.ready = true + this.startHeartbeat() this.options.onState("connected") return } - if (message.type === "ping") return this.send({ type: "pong", at: message.at }) - if (message.type === "http.request") return this.handleHttp(message) - if (message.type === "http.cancel") return this.cancelHttp(message.id) - if (message.type === "socket.open") return this.openSocket(message) - if (message.type === "socket.message") return this.forwardSocketMessage(message) - if (message.type === "socket.close") return this.closeSocket(message.id, message.code, message.reason) + if (!this.ready) { + this.socket?.close(1002, "Remote Control relay handshake required") + return + } + if (message.type === "tunnel.open") { + this.openTunnel(message.id) + return + } + if (message.type === "tunnel.close") { + this.closeTunnel(message.id, message.code, message.reason) + return + } + const tunnel = this.tunnels.get(message.id) + if (!tunnel) return + tunnel.receiveQueue = tunnel.receiveQueue + .then(() => this.handleTunnelFrame(message.id, message.data, message.binary)) + .catch((error) => this.failTunnel(message.id, error)) } - private async handleHttp(message: Extract) { + private openTunnel(id: string): void { + if (this.tunnels.has(id)) { + this.failTunnel(id, new Error("Duplicate Remote Control tunnel")) + return + } + this.tunnels.set(id, { + handshake: createHostHandshake(this.options.encryptionPrivateKey), + receiveQueue: Promise.resolve(), + sendQueue: Promise.resolve(), + httpRequests: new Map(), + localSockets: new Map(), + localSocketQueues: new Map(), + }) + } + + private async handleTunnelFrame(tunnelId: string, encoded: string, binary: boolean): Promise { + const tunnel = this.tunnels.get(tunnelId) + if (!tunnel) return + const bytes = decodeBase64(encoded) + if (!binary) { + if (tunnel.channel) throw new Error("Plaintext received after Remote Control encryption was established") + if (bytes.byteLength > REMOTE_CONTROL_MAX_HANDSHAKE_BYTES) throw new Error("Remote Control handshake is too large") + const accepted = await (await tunnel.handshake).accept(new TextDecoder().decode(bytes)) + tunnel.channel = accepted.channel + this.sendTunnelFrame(tunnelId, new TextEncoder().encode(accepted.ready), false) + return + } + if (!tunnel.channel) throw new Error("Encrypted Remote Control frame arrived before its handshake") + const plaintext = await tunnel.channel.decrypt(bytes) + const message = parseClientMessage(new TextDecoder().decode(plaintext)) + if (!message) throw new Error("Invalid encrypted Remote Control message") + if (message.type === "http.request") void this.handleHttp(tunnelId, tunnel, message).catch((error) => this.failTunnel(tunnelId, error)) + else if (message.type === "http.cancel") this.cancelHttp(tunnel, message.id) + else if (message.type === "socket.open") this.openLocalSocket(tunnelId, tunnel, message) + else if (message.type === "socket.message") this.forwardSocketMessage(tunnel, message) + else if (message.type === "socket.close") this.closeLocalSocket(tunnel, message.id, message.code, message.reason) + } + + private async handleHttp(tunnelId: string, tunnel: TunnelState, message: Extract): Promise { + if (tunnel.httpRequests.has(message.id)) { + await this.sendClient(tunnelId, { type: "http.error", id: message.id, message: "Duplicate remote request identifier" }) + return + } + if (tunnel.httpRequests.size >= MAX_ACTIVE_HTTP_REQUESTS) { + await this.sendClient(tunnelId, { type: "http.error", id: message.id, message: "Too many active remote requests" }) + return + } + if (!ALLOWED_REMOTE_METHODS.has(message.method.toUpperCase())) { + await this.sendClient(tunnelId, { type: "http.error", id: message.id, message: "Remote HTTP method is not allowed" }) + return + } + if (message.body && base64ByteLength(message.body) > REMOTE_CONTROL_MAX_HTTP_BODY_BYTES) { + await this.sendClient(tunnelId, { type: "http.error", id: message.id, message: "Remote request body is too large" }) + return + } const controller = new AbortController() - this.httpRequests.set(message.id, controller) + tunnel.httpRequests.set(message.id, controller) try { - const target = this.localTarget(message.path) - const headers = localHeaders(message.headers, this.options.localCookie()) - const response = await fetch(target, { + const response = await fetch(this.localTarget(message.path), { method: message.method, - headers, + headers: localHeaders(message.headers, this.options.localCookie()), body: message.body ? decodeBase64(message.body) : undefined, dispatcher: this.localDispatcher, signal: controller.signal, redirect: "manual", }) - this.send({ + await this.sendClient(tunnelId, { type: "http.start", id: message.id, status: response.status, @@ -164,26 +270,32 @@ export class RemoteControlConnector { while (true) { const { done, value } = await reader.read() if (done) break - if (value.byteLength) this.send({ type: "http.chunk", id: message.id, data: encodeBase64(value) }) + if (value.byteLength) await this.sendClient(tunnelId, { type: "http.chunk", id: message.id, data: encodeBase64(value) }) } } - this.send({ type: "http.end", id: message.id }) + await this.sendClient(tunnelId, { type: "http.end", id: message.id }) } catch (error) { if (!controller.signal.aborted) { - this.send({ type: "http.error", id: message.id, message: error instanceof Error ? error.message : "Local request failed" }) + await this.sendClient(tunnelId, { + type: "http.error", + id: message.id, + message: error instanceof Error ? error.message : "Local request failed", + }) } } finally { - this.httpRequests.delete(message.id) + tunnel.httpRequests.delete(message.id) } } - private cancelHttp(id: string) { - this.httpRequests.get(id)?.abort() - this.httpRequests.delete(id) + private cancelHttp(tunnel: TunnelState, id: string): void { + tunnel.httpRequests.get(id)?.abort() + tunnel.httpRequests.delete(id) } - private openSocket(message: Extract) { + private openLocalSocket(tunnelId: string, tunnel: TunnelState, message: Extract): void { try { + if (tunnel.localSockets.has(message.id)) throw new Error("Duplicate remote WebSocket identifier") + if (tunnel.localSockets.size >= MAX_LOCAL_SOCKETS) throw new Error("Too many active remote WebSockets") const target = this.localTarget(message.path) target.protocol = target.protocol === "https:" ? "wss:" : "ws:" const socket = new WebSocket(target, { @@ -192,118 +304,138 @@ export class RemoteControlConnector { headers: localHeaders(message.headers, this.options.localCookie()), }) socket.binaryType = "arraybuffer" - this.localSockets.set(message.id, socket) - this.localSocketQueues.set(message.id, []) + tunnel.localSockets.set(message.id, socket) + tunnel.localSocketQueues.set(message.id, []) socket.addEventListener("open", () => { - this.send({ type: "socket.ready", id: message.id, ...(socket.protocol ? { protocol: socket.protocol } : {}) }) - const queued = this.localSocketQueues.get(message.id) ?? [] - this.localSocketQueues.delete(message.id) + void this.sendClient(tunnelId, { type: "socket.ready", id: message.id, ...(socket.protocol ? { protocol: socket.protocol } : {}) }) + const queued = tunnel.localSocketQueues.get(message.id) ?? [] + tunnel.localSocketQueues.delete(message.id) for (const entry of queued) this.sendLocalSocket(socket, entry.data, entry.binary) }) socket.addEventListener("message", (event) => { const binary = typeof event.data !== "string" const bytes = binary ? new Uint8Array(event.data as ArrayBuffer) : new TextEncoder().encode(event.data as string) - this.send({ type: "socket.message", id: message.id, data: encodeBase64(bytes), binary }) + void this.sendClient(tunnelId, { type: "socket.message", id: message.id, data: encodeBase64(bytes), binary }) }) socket.addEventListener("close", (event) => { - this.localSockets.delete(message.id) - this.localSocketQueues.delete(message.id) - this.send({ type: "socket.close", id: message.id, code: event.code, reason: event.reason }) + tunnel.localSockets.delete(message.id) + tunnel.localSocketQueues.delete(message.id) + void this.sendClient(tunnelId, { type: "socket.close", id: message.id, code: event.code, reason: event.reason }) }) socket.addEventListener("error", () => { - this.localSockets.delete(message.id) - this.localSocketQueues.delete(message.id) - this.send({ type: "socket.error", id: message.id, message: "Local WebSocket failed" }) + tunnel.localSockets.delete(message.id) + tunnel.localSocketQueues.delete(message.id) + void this.sendClient(tunnelId, { type: "socket.error", id: message.id, message: "Local WebSocket failed" }) }) } catch (error) { - this.send({ type: "socket.error", id: message.id, message: error instanceof Error ? error.message : "Local WebSocket failed" }) + void this.sendClient(tunnelId, { + type: "socket.error", + id: message.id, + message: error instanceof Error ? error.message : "Local WebSocket failed", + }) } } - private forwardSocketMessage(message: Extract) { - const socket = this.localSockets.get(message.id) + private forwardSocketMessage(tunnel: TunnelState, message: Extract): void { + const socket = tunnel.localSockets.get(message.id) if (!socket) return if (socket.readyState === WebSocket.CONNECTING) { - const queued = this.localSocketQueues.get(message.id) - if (!queued || queued.length >= MAX_QUEUED_SOCKET_MESSAGES) { - this.closeSocket(message.id, 1009, "Too many queued Remote Control messages") - return - } - queued.push({ data: message.data, binary: message.binary }) + const queued = tunnel.localSocketQueues.get(message.id) + const queuedBytes = queued?.reduce((total, entry) => total + base64ByteLength(entry.data), 0) ?? 0 + if (!queued || queued.length >= MAX_QUEUED_SOCKET_MESSAGES + || queuedBytes + base64ByteLength(message.data) > MAX_QUEUED_SOCKET_BYTES) { + this.closeLocalSocket(tunnel, message.id, 1009, "Too many queued Remote Control messages") + } else queued.push({ data: message.data, binary: message.binary }) return } if (socket.readyState === WebSocket.OPEN) this.sendLocalSocket(socket, message.data, message.binary) } - private sendLocalSocket(socket: InstanceType, data: string, binary: boolean) { + private sendLocalSocket(socket: InstanceType, data: string, binary: boolean): void { const bytes = decodeBase64(data) socket.send(binary ? bytes : new TextDecoder().decode(bytes)) } - private closeSocket(id: string, code?: number, reason?: string) { - const socket = this.localSockets.get(id) - this.localSockets.delete(id) - this.localSocketQueues.delete(id) - socket?.close(code, reason) + private closeLocalSocket(tunnel: TunnelState, id: string, code?: number, reason?: string): void { + const socket = tunnel.localSockets.get(id) + tunnel.localSockets.delete(id) + tunnel.localSocketQueues.delete(id) + socket?.close(validCloseCode(code) ? code : undefined, reason?.slice(0, 120)) } - private localTarget(path: string): URL { - if (!path.startsWith("/") || path.startsWith("//")) throw new Error("Invalid remote request path") - const base = new URL(this.options.localUrl()) - const target = new URL(path, base) - if (target.origin !== base.origin) throw new Error("Remote request escaped the local server") - return target + private sendClient(tunnelId: string, message: HostToClientMessage): Promise { + const tunnel = this.tunnels.get(tunnelId) + if (!tunnel?.channel) return Promise.resolve() + const plaintext = new TextEncoder().encode(JSON.stringify(message)) + tunnel.sendQueue = tunnel.sendQueue.then(async () => { + const channel = tunnel.channel + if (!channel || this.tunnels.get(tunnelId) !== tunnel) return + const frame = await channel.encrypt(plaintext) + if (this.tunnels.get(tunnelId) !== tunnel) return + this.sendTunnelFrame(tunnelId, frame, true) + }).catch((error) => this.failTunnel(tunnelId, error)) + return tunnel.sendQueue } - private send(message: HostToRelayMessage) { - if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)) + private sendTunnelFrame(tunnelId: string, bytes: Uint8Array, binary: boolean): void { + this.sendRelay({ type: "tunnel.message", id: tunnelId, data: encodeBase64(bytes), binary }) } - private abortInflight() { - for (const controller of this.httpRequests.values()) controller.abort() - this.httpRequests.clear() - for (const socket of this.localSockets.values()) socket.close(1012, "Remote Control reconnecting") - this.localSockets.clear() - this.localSocketQueues.clear() + private failTunnel(id: string, error: unknown): void { + const reason = error instanceof Error ? error.message : "Encrypted Remote Control tunnel failed" + this.options.logger.warn({ err: error, tunnelId: id }, "Remote Control encrypted tunnel failed") + this.sendRelay({ type: "tunnel.close", id, code: 1008, reason: reason.slice(0, 120) }) + this.closeTunnel(id, 1008, reason) } -} -function relaySocketUrl(relayUrl: string, hostId: string): URL { - const url = new URL(`/api/hosts/${hostId}/connect`, normalizedRelayUrl(relayUrl)) - url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - return url -} + private closeTunnel(id: string, code?: number, reason?: string): void { + const tunnel = this.tunnels.get(id) + if (!tunnel) return + this.tunnels.delete(id) + for (const controller of tunnel.httpRequests.values()) controller.abort() + for (const socket of tunnel.localSockets.values()) { + socket.close(validCloseCode(code) ? code : undefined, reason?.slice(0, 120)) + } + tunnel.httpRequests.clear() + tunnel.localSockets.clear() + tunnel.localSocketQueues.clear() + } -export function normalizedRelayUrl(value: string): URL { - const url = new URL(value) - if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) { - throw new Error("Remote Control relay must use HTTPS") + private closeAllTunnels(): void { + for (const id of Array.from(this.tunnels.keys())) this.closeTunnel(id, 1012, "Remote Control reconnecting") } - url.pathname = "/" - url.search = "" - url.hash = "" - return url -} -function isLoopbackHostname(hostname: string): boolean { - const normalized = hostname.toLowerCase() - return normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.startsWith("127.") -} + private sendRelay(message: HostToRelayMessage): void { + if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)) + } -function localHeaders(entries: HeaderEntries, cookie: string): Headers { - const headers = new Headers() - for (const [name, value] of entries) { - if (!REQUEST_HEADER_BLOCKLIST.has(name.toLowerCase())) headers.append(name, value) + private startHeartbeat(): void { + this.stopHeartbeat() + this.lastHeartbeatAt = Date.now() + this.heartbeatTimer = setInterval(() => { + const socket = this.socket + if (!this.ready || socket?.readyState !== WebSocket.OPEN) return + if (Date.now() - this.lastHeartbeatAt > HEARTBEAT_TIMEOUT_MS) { + socket.close(1012, "Remote Control relay heartbeat timed out") + this.onClosed(socket, "Remote Control relay stopped responding") + return + } + socket.send(REMOTE_CONTROL_HEARTBEAT_REQUEST) + }, HEARTBEAT_INTERVAL_MS) + this.heartbeatTimer.unref() } - headers.set("Cookie", cookie) - headers.set("X-CodeNomad-Remote-Control", "1") - return headers -} -function responseHeaders(headers: Headers): HeaderEntries { - const entries: HeaderEntries = [] - headers.forEach((value, name) => { - if (!RESPONSE_HEADER_BLOCKLIST.has(name.toLowerCase())) entries.push([name, value]) - }) - return entries + private stopHeartbeat(): void { + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + this.lastHeartbeatAt = 0 + } + + private localTarget(path: string): URL { + if (!allowedRemotePath(path) || path.startsWith("//") || path.includes("\\")) throw new Error("Invalid remote request path") + const base = new URL(this.options.localUrl()) + const target = new URL(path, base) + if (target.origin !== base.origin || !allowedRemotePath(target.pathname)) throw new Error("Remote request escaped the local server") + return target + } } diff --git a/packages/server/src/remote-control/identity.test.ts b/packages/server/src/remote-control/identity.test.ts index f3a8ab72a..8fdc27ad4 100644 --- a/packages/server/src/remote-control/identity.test.ts +++ b/packages/server/src/remote-control/identity.test.ts @@ -11,6 +11,9 @@ test("Remote Control identity is random, persistent, and never stores malformed const first = loadOrCreateRemoteControlIdentity(directory) assert.match(first.hostId, /^[a-f0-9]{32}$/) assert.match(first.secret, /^[A-Za-z0-9_-]{40,}$/) + assert.equal(first.encryptionPublicKey.crv, "P-256") + assert.equal(first.encryptionPrivateKey.crv, "P-256") + assert.equal(typeof first.encryptionPrivateKey.d, "string") assert.deepEqual(loadOrCreateRemoteControlIdentity(directory), first) const stored = JSON.parse(readFileSync(join(directory, "remote-control.json"), "utf8")) @@ -20,6 +23,46 @@ test("Remote Control identity is random, persistent, and never stores malformed } }) +test("legacy relay identities gain encryption keys without changing their address or secret", () => { + const directory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-")) + const legacy = { hostId: "a".repeat(32), secret: "b".repeat(48) } + try { + writeFileSync(join(directory, "remote-control.json"), JSON.stringify(legacy)) + const identity = loadOrCreateRemoteControlIdentity(directory) + assert.equal(identity.hostId, legacy.hostId) + assert.equal(identity.secret, legacy.secret) + assert.equal(identity.encryptionPublicKey.crv, "P-256") + assert.equal(typeof identity.encryptionPrivateKey.d, "string") + assert.deepEqual(loadOrCreateRemoteControlIdentity(directory), identity) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test("mismatched encryption keys are replaced without changing the relay identity", () => { + const directory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-")) + try { + const first = loadOrCreateRemoteControlIdentity(directory) + const otherDirectory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-other-")) + try { + const other = loadOrCreateRemoteControlIdentity(otherDirectory) + writeFileSync(join(directory, "remote-control.json"), JSON.stringify({ + ...first, + encryptionPublicKey: other.encryptionPublicKey, + })) + const repaired = loadOrCreateRemoteControlIdentity(directory) + assert.equal(repaired.hostId, first.hostId) + assert.equal(repaired.secret, first.secret) + assert.notDeepEqual(repaired.encryptionPrivateKey, first.encryptionPrivateKey) + assert.notDeepEqual(repaired.encryptionPublicKey, other.encryptionPublicKey) + } finally { + rmSync(otherDirectory, { recursive: true, force: true }) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + test("malformed existing identities are replaced atomically", () => { const directory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-")) try { diff --git a/packages/server/src/remote-control/identity.ts b/packages/server/src/remote-control/identity.ts index 260a44b45..fca66f70e 100644 --- a/packages/server/src/remote-control/identity.ts +++ b/packages/server/src/remote-control/identity.ts @@ -1,24 +1,33 @@ -import { randomBytes } from "crypto" +import { createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, type JsonWebKey as NodeJsonWebKey } from "crypto" import fs from "fs" import path from "path" export interface RemoteControlIdentity { hostId: string secret: string + encryptionPrivateKey: JsonWebKey + encryptionPublicKey: JsonWebKey } const HOST_ID_PATTERN = /^[a-f0-9]{32}$/ -const SECRET_PATTERN = /^[A-Za-z0-9_-]{40,}$/ +const SECRET_PATTERN = /^[A-Za-z0-9_-]{40,128}$/ export function loadOrCreateRemoteControlIdentity(configDir: string): RemoteControlIdentity { const filePath = path.join(configDir, "remote-control.json") const existing = readIdentity(filePath) if (existing) return existing + const encryption = createEncryptionKeyPair() const identity = { hostId: randomBytes(16).toString("hex"), secret: randomBytes(32).toString("base64url"), + ...encryption, } + writeIdentity(filePath, identity) + return identity +} + +function writeIdentity(filePath: string, identity: RemoteControlIdentity): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }) const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp` fs.writeFileSync(temporary, `${JSON.stringify(identity, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }) @@ -28,16 +37,53 @@ export function loadOrCreateRemoteControlIdentity(configDir: string): RemoteCont } catch { // Windows ACLs and some network filesystems do not implement POSIX modes. } - return identity } function readIdentity(filePath: string): RemoteControlIdentity | null { try { const value = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial if (!HOST_ID_PATTERN.test(value.hostId ?? "") || !SECRET_PATTERN.test(value.secret ?? "")) return null - return { hostId: value.hostId!, secret: value.secret! } + if (isPrivateKey(value.encryptionPrivateKey) && isPublicKey(value.encryptionPublicKey) + && isMatchingKeyPair(value.encryptionPrivateKey, value.encryptionPublicKey)) { + return { + hostId: value.hostId!, + secret: value.secret!, + encryptionPrivateKey: value.encryptionPrivateKey, + encryptionPublicKey: value.encryptionPublicKey, + } + } + const migrated = { hostId: value.hostId!, secret: value.secret!, ...createEncryptionKeyPair() } + writeIdentity(filePath, migrated) + return migrated } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null throw error } } + +function createEncryptionKeyPair(): Pick { + const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) + return { + encryptionPrivateKey: privateKey.export({ format: "jwk" }), + encryptionPublicKey: publicKey.export({ format: "jwk" }), + } +} + +function isPublicKey(value: JsonWebKey | undefined): value is JsonWebKey { + return value?.kty === "EC" && value.crv === "P-256" + && typeof value.x === "string" && typeof value.y === "string" +} + +function isPrivateKey(value: JsonWebKey | undefined): value is JsonWebKey { + return isPublicKey(value) && typeof value.d === "string" +} + +function isMatchingKeyPair(privateKey: JsonWebKey, publicKey: JsonWebKey): boolean { + try { + const derived = createPublicKey(createPrivateKey({ key: privateKey as NodeJsonWebKey, format: "jwk" })).export({ format: "jwk" }) + return derived.kty === publicKey.kty && derived.crv === publicKey.crv + && derived.x === publicKey.x && derived.y === publicKey.y + } catch { + return false + } +} diff --git a/packages/server/src/remote-control/manager.ts b/packages/server/src/remote-control/manager.ts index 180967088..977329217 100644 --- a/packages/server/src/remote-control/manager.ts +++ b/packages/server/src/remote-control/manager.ts @@ -4,6 +4,7 @@ import type { RemoteControlStartResponse, RemoteControlStatus, } from "@codenomad/remote-control-protocol" +import { encodeBase64, REMOTE_CONTROL_PROTOCOL_VERSION } from "@codenomad/remote-control-protocol" import { fetch } from "undici" import type { Logger } from "../logger" import { RemoteControlConnector, normalizedRelayUrl, type ConnectorState } from "./connector" @@ -31,6 +32,7 @@ export class RemoteControlManager { relayUrl: options.relayUrl, hostId: options.identity.hostId, secret: options.identity.secret, + encryptionPrivateKey: options.identity.encryptionPrivateKey, localUrl: options.localUrl, localCookie: options.localCookie, logger: options.logger, @@ -81,9 +83,17 @@ export class RemoteControlManager { }) if (!response.ok) throw new Error(await relayError(response, "Could not create a pairing link")) const payload = await response.json() as { token?: unknown; expiresAt?: unknown } - if (typeof payload.token !== "string" || typeof payload.expiresAt !== "string") throw new Error("Relay returned an invalid pairing link") + if (typeof payload.token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(payload.token) + || typeof payload.expiresAt !== "string" || !Number.isFinite(Date.parse(payload.expiresAt))) { + throw new Error("Relay returned an invalid pairing link") + } const origin = remoteOrigin(relay, this.options.identity.hostId) - return { url: `${origin}/__codenomad/pair#${encodeURIComponent(payload.token)}`, expiresAt: payload.expiresAt } + const pairingFragment = encodeBase64(new TextEncoder().encode(JSON.stringify({ + protocol: REMOTE_CONTROL_PROTOCOL_VERSION, + token: payload.token, + hostPublicKey: this.options.identity.encryptionPublicKey, + }))) + return { url: `${origin}/__codenomad/pair#${encodeURIComponent(pairingFragment)}`, expiresAt: payload.expiresAt } } async devices(): Promise { diff --git a/packages/ui/package.json b/packages/ui/package.json index 6997e4a49..b760e75e3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,11 +6,13 @@ "type": "module", "scripts": { "dev": "vite dev", + "prebuild": "npm run build --workspace @codenomad/remote-control-protocol", "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@codenomad/remote-control-protocol": "0.1.0", "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", "@opencode-ai/client": "beta", diff --git a/packages/ui/src/bootstrap.ts b/packages/ui/src/bootstrap.ts new file mode 100644 index 000000000..fd5118ccf --- /dev/null +++ b/packages/ui/src/bootstrap.ts @@ -0,0 +1,6 @@ +import { installRemoteControlTransport } from "./lib/remote-control/tunnel" + +void (async () => { + await installRemoteControlTransport() + await import("./main") +})() diff --git a/packages/ui/src/lib/remote-control/event-source.test.ts b/packages/ui/src/lib/remote-control/event-source.test.ts new file mode 100644 index 000000000..5c100293a --- /dev/null +++ b/packages/ui/src/lib/remote-control/event-source.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { TunnelEventSource } from "./event-source" + +test("tunneled EventSource parses named and multiline SSE events", async () => { + const originalFetch = globalThis.fetch + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + globalThis.fetch = async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("id: 7\nevent: codenomad.client.ping\ndata: {\"ts\":1}\n\n")) + controller.enqueue(new TextEncoder().encode("data: first\ndata: second\n\n")) + controller.close() + }, + }), { status: 200 }) + + try { + const source = new TunnelEventSource("/api/events", { withCredentials: true }) + const named = new Promise((resolve) => source.addEventListener("codenomad.client.ping", (event) => resolve(event as MessageEvent), { once: true })) + const message = new Promise((resolve) => { source.onmessage = (event) => resolve(event) }) + assert.equal((await named).data, "{\"ts\":1}") + assert.equal((await message).data, "first\nsecond") + assert.equal(source.withCredentials, true) + source.close() + assert.equal(source.readyState, TunnelEventSource.CLOSED) + } finally { + globalThis.fetch = originalFetch + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + } +}) + +test("tunneled EventSource reconnects with the last event identifier", async () => { + const originalFetch = globalThis.fetch + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + const requests: RequestInit[] = [] + globalThis.fetch = async (_input, init) => { + requests.push(init ?? {}) + const body = requests.length === 1 ? "retry: 1\nid: event-7\ndata: first\n\n" : "data: second\n\n" + return new Response(body, { status: 200 }) + } + + try { + const source = new TunnelEventSource("/api/events") + const messages: string[] = [] + await new Promise((resolve) => { + source.onmessage = (event) => { + messages.push(String(event.data)) + if (messages.length === 2) resolve() + } + }) + source.close() + assert.deepEqual(messages, ["first", "second"]) + assert.equal(new Headers(requests[1].headers).get("last-event-id"), "event-7") + } finally { + globalThis.fetch = originalFetch + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + } +}) diff --git a/packages/ui/src/lib/remote-control/event-source.ts b/packages/ui/src/lib/remote-control/event-source.ts new file mode 100644 index 000000000..328c97276 --- /dev/null +++ b/packages/ui/src/lib/remote-control/event-source.ts @@ -0,0 +1,120 @@ +type EventHandler = ((event: Event) => unknown) | null + +export class TunnelEventSource extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 + readonly CONNECTING = 0 + readonly OPEN = 1 + readonly CLOSED = 2 + readonly withCredentials: boolean + readonly url: string + readyState = TunnelEventSource.CONNECTING + onopen: EventHandler = null + onmessage: ((event: MessageEvent) => unknown) | null = null + onerror: EventHandler = null + private controller: AbortController | null = null + private reconnectDelay = 3_000 + private lastEventId = "" + + constructor(url: string | URL, options?: EventSourceInit) { + super() + this.url = new URL(url, window.location.href).toString() + this.withCredentials = options?.withCredentials === true + void this.connect() + } + + close(): void { + if (this.readyState === TunnelEventSource.CLOSED) return + this.readyState = TunnelEventSource.CLOSED + this.controller?.abort() + this.controller = null + const event = new Event("close") + this.dispatchEvent(event) + ;(this as EventSource & { onclose?: EventHandler }).onclose?.(event) + } + + private async connect(): Promise { + while (this.readyState !== TunnelEventSource.CLOSED) { + this.controller = new AbortController() + try { + const headers = new Headers({ Accept: "text/event-stream" }) + if (this.lastEventId) headers.set("Last-Event-ID", this.lastEventId) + const response = await fetch(this.url, { + headers, + credentials: this.withCredentials ? "include" : "same-origin", + signal: this.controller.signal, + cache: "no-store", + }) + if (response.status === 204) { + this.readyState = TunnelEventSource.CLOSED + return + } + if (!response.ok || !response.body) throw new Error(`Event stream failed with HTTP ${response.status}`) + this.readyState = TunnelEventSource.OPEN + const open = new Event("open") + this.dispatchEvent(open) + this.onopen?.(open) + await this.read(response.body) + } catch { + if (this.readyState === TunnelEventSource.CLOSED) return + } + if (this.readyState === TunnelEventSource.CLOSED) return + this.readyState = TunnelEventSource.CONNECTING + const error = new Event("error") + this.dispatchEvent(error) + this.onerror?.(error) + await new Promise((resolve) => setTimeout(resolve, this.reconnectDelay)) + } + } + + private async read(stream: ReadableStream): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = "" + let eventName = "message" + let eventData: string[] = [] + let eventId = this.lastEventId + + const dispatch = () => { + if (!eventData.length) { + eventName = "message" + return + } + this.lastEventId = eventId + const event = new MessageEvent(eventName, { data: eventData.join("\n"), lastEventId: eventId }) + this.dispatchEvent(event) + if (eventName === "message") this.onmessage?.(event) + eventName = "message" + eventData = [] + } + + while (!this.controller?.signal.aborted) { + const { done, value } = await reader.read() + buffer += decoder.decode(value, { stream: !done }) + let newline = buffer.indexOf("\n") + while (newline >= 0) { + let line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + if (line.endsWith("\r")) line = line.slice(0, -1) + if (!line) dispatch() + else if (!line.startsWith(":")) { + const separator = line.indexOf(":") + const field = separator < 0 ? line : line.slice(0, separator) + let data = separator < 0 ? "" : line.slice(separator + 1) + if (data.startsWith(" ")) data = data.slice(1) + if (field === "event") eventName = data || "message" + else if (field === "data") eventData.push(data) + else if (field === "id" && !data.includes("\0")) eventId = data + else if (field === "retry" && /^\d+$/.test(data)) this.reconnectDelay = Number(data) + } + newline = buffer.indexOf("\n") + } + if (done) { + dispatch() + break + } + } + } + +} diff --git a/packages/ui/src/lib/remote-control/tunnel.ts b/packages/ui/src/lib/remote-control/tunnel.ts new file mode 100644 index 000000000..be8eb8833 --- /dev/null +++ b/packages/ui/src/lib/remote-control/tunnel.ts @@ -0,0 +1,413 @@ +import { + createClientHandshake, + decodeBase64, + encodeBase64, + REMOTE_CONTROL_MAX_HTTP_BODY_BYTES, + type ClientToHostMessage, + type EncryptedChannel, + type HeaderEntries, + type HostToClientMessage, +} from "@codenomad/remote-control-protocol" +import { TunnelEventSource } from "./event-source" +import { + TunnelWebSocket, + tunnelAwareWebSocket, + type RemoteSocketBridge, + type RemoteSocketData, +} from "./web-socket" + +const HOST_KEY_STORAGE = "codenomad.remote-control.host-public-key" +const HTTP_IDLE_TIMEOUT_MS = 30_000 +const FETCH_PATH_PREFIXES = ["/api/", "/workspaces/"] + +interface RemoteControlBootstrap { + tunnelPath: string +} + +interface PendingHttp { + resolve: (response: Response) => void + reject: (error: Error) => void + controller?: ReadableStreamDefaultController + queued: Uint8Array[] + ended: boolean + timeout: ReturnType + cleanup: () => void +} + +interface PendingSocket { + socket: TunnelWebSocket + opening: Promise + transmission: Promise +} + +export async function installRemoteControlTransport(): Promise { + const bootstrap = window.__CODENOMAD_REMOTE_CONTROL__ ?? await discoverRemoteControl() + if (!bootstrap) return + window.__CODENOMAD_REMOTE_CONTROL__ = bootstrap + const hostPublicKey = loadHostPublicKey() + const NativeWebSocket = window.WebSocket + const tunnel = new RemoteControlTunnel(bootstrap, hostPublicKey, NativeWebSocket) + const nativeFetch = globalThis.fetch.bind(globalThis) + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input) + if (url.origin === window.location.origin && FETCH_PATH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) { + return tunnel.fetch(input, init) + } + return nativeFetch(input, init) + }) as typeof globalThis.fetch + window.EventSource = TunnelEventSource as unknown as typeof EventSource + window.WebSocket = tunnelAwareWebSocket(NativeWebSocket, tunnel, shouldTunnelUrl) +} + +async function discoverRemoteControl(): Promise { + try { + const response = await globalThis.fetch("/__codenomad/bootstrap", { credentials: "include", cache: "no-store" }) + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return null + const value = await response.json() as Partial + return typeof value.tunnelPath === "string" && value.tunnelPath.startsWith("/") && !value.tunnelPath.startsWith("//") + ? { tunnelPath: value.tunnelPath } + : null + } catch { + return null + } +} + +class RemoteControlTunnel implements RemoteSocketBridge { + private socket: WebSocket | null = null + private channel: EncryptedChannel | null = null + private connection: Promise | null = null + private sendQueue = Promise.resolve() + private receiveQueue = Promise.resolve() + private readonly pendingHttp = new Map() + private readonly pendingSockets = new Map() + + constructor( + private readonly bootstrap: RemoteControlBootstrap, + private readonly hostPublicKey: JsonWebKey, + private readonly NativeWebSocket: typeof WebSocket, + ) {} + + async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const source = input instanceof Request ? input : new Request(requestUrl(input), init) + const request = input instanceof Request && init ? new Request(input, init) : source + const id = crypto.randomUUID() + if (request.signal.aborted) throw new DOMException("The operation was aborted", "AbortError") + const bodyBytes = request.method === "GET" || request.method === "HEAD" + ? new Uint8Array() + : new Uint8Array(await request.arrayBuffer()) + if (bodyBytes.byteLength > REMOTE_CONTROL_MAX_HTTP_BODY_BYTES) return Response.json({ error: "Remote request body is too large" }, { status: 413 }) + + await this.ensureConnected() + const abort = () => { + void this.send({ type: "http.cancel", id }) + this.failHttp(id, new DOMException("The operation was aborted", "AbortError")) + } + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + void this.send({ type: "http.cancel", id }) + this.failHttp(id, new Error("Remote request timed out")) + }, HTTP_IDLE_TIMEOUT_MS) + this.pendingHttp.set(id, { + resolve, + reject, + queued: [], + ended: false, + timeout, + cleanup: () => request.signal.removeEventListener("abort", abort), + }) + }) + request.signal.addEventListener("abort", abort, { once: true }) + if (request.signal.aborted) abort() + if (request.signal.aborted) return response + await this.send({ + type: "http.request", + id, + method: request.method, + path: `${requestUrl(request).pathname}${requestUrl(request).search}`, + headers: requestHeaders(request.headers), + ...(bodyBytes.byteLength ? { body: encodeBase64(bodyBytes) } : {}), + }).catch((error) => this.failHttp(id, error instanceof Error ? error : new Error("Remote request failed"))) + return response + } + + connectSocket(socket: TunnelWebSocket, url: URL, protocols: string[]): void { + const opening = this.ensureConnected() + .then(() => this.send({ + type: "socket.open", + id: socket.id, + path: `${url.pathname}${url.search}`, + headers: [], + protocols, + })) + .catch(() => this.failSocket(socket.id)) + this.pendingSockets.set(socket.id, { socket, opening, transmission: opening }) + } + + transmitSocket(socket: TunnelWebSocket, data: RemoteSocketData): void { + const pending = this.pendingSockets.get(socket.id) + if (!pending || pending.socket !== socket) return + const transmission = pending.transmission.then(async () => { + const payload = await socketPayload(data) + await this.send({ type: "socket.message", id: socket.id, data: encodeBase64(payload.bytes), binary: payload.binary }) + }) + pending.transmission = transmission.catch(() => undefined) + void transmission.catch(() => this.failSocket(socket.id)) + } + + disconnectSocket(socket: TunnelWebSocket, code?: number, reason?: string): void { + const pending = this.pendingSockets.get(socket.id) + if (!pending || pending.socket !== socket) { + socket.finish(code, reason) + return + } + void pending.transmission + .then(() => this.send({ type: "socket.close", id: socket.id, code, reason })) + .catch(() => undefined) + } + + private ensureConnected(): Promise { + if (this.channel && this.socket?.readyState === WebSocket.OPEN) return Promise.resolve() + if (this.connection) return this.connection + this.connection = this.connect().finally(() => { + this.connection = null + }) + return this.connection + } + + private async connect(): Promise { + const handshake = await createClientHandshake(this.hostPublicKey) + const url = new URL(this.bootstrap.tunnelPath, window.location.href) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + const socket = new this.NativeWebSocket(url) + socket.binaryType = "arraybuffer" + this.socket = socket + this.channel = null + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Remote Control encryption handshake timed out")), 15_000) + socket.addEventListener("open", () => socket.send(handshake.hello), { once: true }) + socket.addEventListener("message", (event) => { + if (typeof event.data !== "string" || this.channel) return + void handshake.accept(event.data).then((channel) => { + clearTimeout(timeout) + if (this.socket !== socket) return + this.channel = channel + resolve() + }).catch(reject) + }) + socket.addEventListener("close", () => { + clearTimeout(timeout) + reject(new Error("Remote Control tunnel closed during encryption handshake")) + }, { once: true }) + socket.addEventListener("error", () => reject(new Error("Remote Control tunnel connection failed")), { once: true }) + }) + } catch (error) { + if (this.socket === socket) { + this.socket = null + this.channel = null + } + if (socket.readyState < WebSocket.CLOSING) socket.close() + throw error + } + this.sendQueue = Promise.resolve() + this.receiveQueue = Promise.resolve() + const channel = this.channel + if (!channel) throw new Error("Remote Control encrypted channel is unavailable") + socket.addEventListener("message", (event) => { + if (this.socket !== socket || this.channel !== channel) return + if (!(event.data instanceof ArrayBuffer)) { + this.close(1002, "Plaintext received after encryption handshake") + return + } + const receive = this.receiveQueue.then(async () => { + if (this.socket !== socket || this.channel !== channel) return + await this.receive(channel, new Uint8Array(event.data)) + }) + this.receiveQueue = receive.catch(() => { + if (this.socket === socket) this.close(1008, "Encrypted Remote Control frame failed") + }) + }) + socket.addEventListener("close", () => this.onSocketClosed(socket)) + socket.addEventListener("error", () => this.onSocketClosed(socket)) + } + + private send(message: ClientToHostMessage): Promise { + const plaintext = new TextEncoder().encode(JSON.stringify(message)) + const socket = this.socket + const channel = this.channel + const send = this.sendQueue.then(async () => { + if (!channel || !socket || this.channel !== channel || this.socket !== socket || socket.readyState !== WebSocket.OPEN) { + throw new Error("Remote Control tunnel is disconnected") + } + const frame = await channel.encrypt(plaintext) + if (this.channel !== channel || this.socket !== socket || socket.readyState !== WebSocket.OPEN) { + throw new Error("Remote Control tunnel is disconnected") + } + socket.send(frame) + }) + this.sendQueue = send.catch(() => undefined) + return send + } + + private async receive(channel: EncryptedChannel, frame: Uint8Array): Promise { + const message = parseHostMessage(new TextDecoder().decode(await channel.decrypt(frame))) + if (!message) throw new Error("Invalid encrypted Remote Control response") + if (message.type === "http.start") this.startHttp(message) + else if (message.type === "http.chunk") this.chunkHttp(message.id, decodeBase64(message.data)) + else if (message.type === "http.end") this.endHttp(message.id) + else if (message.type === "http.error") this.failHttp(message.id, new Error(message.message)) + else if (message.type === "socket.ready") this.pendingSockets.get(message.id)?.socket.accept(message.protocol) + else if (message.type === "socket.message") this.pendingSockets.get(message.id)?.socket.receive(decodeBase64(message.data), message.binary) + else if (message.type === "socket.error") this.failSocket(message.id) + else if (message.type === "socket.close") this.finishSocket(message.id, message.code, message.reason) + } + + private startHttp(message: Extract): void { + const pending = this.pendingHttp.get(message.id) + if (!pending) return + this.refreshTimeout(message.id) + const stream = new ReadableStream({ + start: (controller) => { + pending.controller = controller + for (const chunk of pending.queued) controller.enqueue(chunk) + pending.queued.length = 0 + if (pending.ended) controller.close() + }, + cancel: () => { + void this.send({ type: "http.cancel", id: message.id }) + clearTimeout(pending.timeout) + pending.cleanup() + this.pendingHttp.delete(message.id) + }, + }) + pending.resolve(new Response(responseMustNotHaveBody(message.status) ? null : stream, { + status: message.status, + headers: message.headers, + })) + } + + private chunkHttp(id: string, chunk: Uint8Array): void { + const pending = this.pendingHttp.get(id) + if (!pending || pending.ended) return + this.refreshTimeout(id) + if (pending.controller) pending.controller.enqueue(chunk) + else pending.queued.push(chunk) + } + + private endHttp(id: string): void { + const pending = this.pendingHttp.get(id) + if (!pending) return + clearTimeout(pending.timeout) + pending.cleanup() + pending.ended = true + pending.controller?.close() + this.pendingHttp.delete(id) + } + + private failHttp(id: string, error: Error): void { + const pending = this.pendingHttp.get(id) + if (!pending) return + clearTimeout(pending.timeout) + pending.cleanup() + pending.reject(error) + pending.controller?.error(error) + this.pendingHttp.delete(id) + } + + private refreshTimeout(id: string): void { + const pending = this.pendingHttp.get(id) + if (!pending) return + clearTimeout(pending.timeout) + pending.timeout = setTimeout(() => { + void this.send({ type: "http.cancel", id }) + this.failHttp(id, new Error("Remote response timed out")) + }, HTTP_IDLE_TIMEOUT_MS) + } + + private close(code?: number, reason?: string): void { + const socket = this.socket + this.socket = null + this.channel = null + if (socket && code && socket.readyState < WebSocket.CLOSING) socket.close(code, reason) + for (const id of Array.from(this.pendingHttp.keys())) this.failHttp(id, new Error("Remote Control tunnel disconnected")) + for (const id of Array.from(this.pendingSockets.keys())) this.finishSocket(id, 1006, "Remote Control tunnel disconnected", false) + } + + private onSocketClosed(socket: WebSocket): void { + if (this.socket === socket) this.close() + } + + private failSocket(id: string): void { + const pending = this.pendingSockets.get(id) + if (!pending) return + pending.socket.fail() + void this.send({ type: "socket.close", id, code: 1000, reason: "Remote WebSocket failed" }).catch(() => undefined) + this.finishSocket(id, 1006, "Remote WebSocket failed", false) + } + + private finishSocket(id: string, code?: number, reason?: string, wasClean = true): void { + const pending = this.pendingSockets.get(id) + if (!pending) return + this.pendingSockets.delete(id) + pending.socket.finish(code, reason, wasClean) + } +} + +function loadHostPublicKey(): JsonWebKey { + const raw = localStorage.getItem(HOST_KEY_STORAGE) + if (!raw) throw new Error("Remote Control encryption identity is missing") + const value = JSON.parse(raw) as JsonWebKey + if (value.kty !== "EC" || value.crv !== "P-256" || typeof value.x !== "string" || typeof value.y !== "string") { + throw new Error("Remote Control encryption identity is invalid") + } + return value +} + +function requestUrl(input: RequestInfo | URL): URL { + if (input instanceof Request) return new URL(input.url) + return new URL(input, window.location.href) +} + +function shouldTunnelUrl(url: URL): boolean { + const protocol = url.protocol === "ws:" ? "http:" : url.protocol === "wss:" ? "https:" : url.protocol + const comparable = new URL(url) + comparable.protocol = protocol + return comparable.origin === window.location.origin + && FETCH_PATH_PREFIXES.some((prefix) => comparable.pathname.startsWith(prefix)) +} + +async function socketPayload(data: RemoteSocketData): Promise<{ bytes: Uint8Array; binary: boolean }> { + if (typeof data === "string") return { bytes: new TextEncoder().encode(data), binary: false } + if (data instanceof Blob) return { bytes: new Uint8Array(await data.arrayBuffer()), binary: true } + if (ArrayBuffer.isView(data)) { + return { bytes: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), binary: true } + } + return { bytes: new Uint8Array(data), binary: true } +} + +function requestHeaders(headers: Headers): HeaderEntries { + const result: HeaderEntries = [] + headers.forEach((value, name) => result.push([name, value])) + return result +} + +function parseHostMessage(value: string): HostToClientMessage | null { + try { + const message = JSON.parse(value) as Partial + if (typeof message.id !== "string" || typeof message.type !== "string") return null + if (message.type === "http.end") return message as HostToClientMessage + if (message.type === "http.chunk" && typeof message.data === "string") return message as HostToClientMessage + if (message.type === "http.error" && typeof message.message === "string") return message as HostToClientMessage + if (message.type === "http.start" && typeof message.status === "number" && Array.isArray(message.headers)) return message as HostToClientMessage + if (message.type === "socket.ready" || message.type === "socket.close") return message as HostToClientMessage + if (message.type === "socket.message" && typeof message.data === "string" && typeof message.binary === "boolean") return message as HostToClientMessage + if (message.type === "socket.error" && typeof message.message === "string") return message as HostToClientMessage + return null + } catch { + return null + } +} + +function responseMustNotHaveBody(status: number): boolean { + return status === 204 || status === 205 || status === 304 +} diff --git a/packages/ui/src/lib/remote-control/web-socket.test.ts b/packages/ui/src/lib/remote-control/web-socket.test.ts new file mode 100644 index 000000000..47b080855 --- /dev/null +++ b/packages/ui/src/lib/remote-control/web-socket.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { TunnelWebSocket, type RemoteSocketBridge } from "./web-socket" + +test("tunneled WebSocket preserves text, binary, protocol, and close events", () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + const originalCloseEvent = Object.getOwnPropertyDescriptor(globalThis, "CloseEvent") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + if (typeof CloseEvent === "undefined") { + Object.defineProperty(globalThis, "CloseEvent", { + configurable: true, + value: class extends Event { + readonly code: number + readonly reason: string + readonly wasClean: boolean + constructor(type: string, init: CloseEventInit) { + super(type) + this.code = init.code ?? 0 + this.reason = init.reason ?? "" + this.wasClean = init.wasClean ?? false + } + }, + }) + } + const calls: string[] = [] + const bridge: RemoteSocketBridge = { + connectSocket: (_socket, url, protocols) => calls.push(`open:${url.pathname}:${protocols.join(",")}`), + transmitSocket: (_socket, data) => calls.push(`send:${typeof data === "string" ? data : "binary"}`), + disconnectSocket: (_socket, code, reason) => calls.push(`close:${code}:${reason}`), + } + + try { + const socket = new TunnelWebSocket("/workspaces/demo/socket", ["v2"], bridge) + const events: string[] = [] + socket.onopen = () => events.push("open") + socket.onmessage = (event) => events.push(`message:${event.data}`) + socket.onclose = (event) => events.push(`close:${event.code}:${event.wasClean}`) + socket.accept("v2") + socket.receive(new TextEncoder().encode("hello"), false) + socket.send("request") + socket.close(1000, "done") + socket.finish(1000, "done") + + assert.equal(socket.url, "wss://host.remote.example/workspaces/demo/socket") + assert.equal(socket.protocol, "v2") + assert.equal(socket.readyState, TunnelWebSocket.CLOSED) + assert.deepEqual(calls, ["open:/workspaces/demo/socket:v2", "send:request", "close:1000:done"]) + assert.deepEqual(events, ["open", "message:hello", "close:1000:true"]) + } finally { + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + if (originalCloseEvent) Object.defineProperty(globalThis, "CloseEvent", originalCloseEvent) + else delete (globalThis as { CloseEvent?: unknown }).CloseEvent + } +}) diff --git a/packages/ui/src/lib/remote-control/web-socket.ts b/packages/ui/src/lib/remote-control/web-socket.ts new file mode 100644 index 000000000..cacb397b0 --- /dev/null +++ b/packages/ui/src/lib/remote-control/web-socket.ts @@ -0,0 +1,133 @@ +export type RemoteSocketData = string | ArrayBufferLike | ArrayBufferView | Blob + +export interface RemoteSocketBridge { + connectSocket(socket: TunnelWebSocket, url: URL, protocols: string[]): void + transmitSocket(socket: TunnelWebSocket, data: RemoteSocketData): void + disconnectSocket(socket: TunnelWebSocket, code?: number, reason?: string): void +} + +type OpenHandler = ((event: Event) => unknown) | null +type MessageHandler = ((event: MessageEvent) => unknown) | null +type CloseHandler = ((event: CloseEvent) => unknown) | null + +export class TunnelWebSocket extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + + readonly CONNECTING = TunnelWebSocket.CONNECTING + readonly OPEN = TunnelWebSocket.OPEN + readonly CLOSING = TunnelWebSocket.CLOSING + readonly CLOSED = TunnelWebSocket.CLOSED + readonly id = crypto.randomUUID() + readonly url: string + readonly extensions = "" + binaryType: BinaryType = "blob" + bufferedAmount = 0 + protocol = "" + readyState = TunnelWebSocket.CONNECTING + onopen: OpenHandler = null + onmessage: MessageHandler = null + onerror: OpenHandler = null + onclose: CloseHandler = null + + constructor(url: string | URL, protocols: string | string[] | undefined, private readonly bridge: RemoteSocketBridge) { + super() + const target = new URL(url, window.location.href) + if (target.protocol === "http:") target.protocol = "ws:" + else if (target.protocol === "https:") target.protocol = "wss:" + if ((target.protocol !== "ws:" && target.protocol !== "wss:") || target.hash) { + throw new DOMException("Invalid WebSocket URL", "SyntaxError") + } + this.url = target.toString() + bridge.connectSocket(this, target, normalizeProtocols(protocols)) + } + + send(data: RemoteSocketData): void { + if (this.readyState !== TunnelWebSocket.OPEN) throw new DOMException("WebSocket is not open", "InvalidStateError") + this.bridge.transmitSocket(this, data) + } + + close(code?: number, reason = ""): void { + validateClose(code, reason) + if (this.readyState === TunnelWebSocket.CLOSING || this.readyState === TunnelWebSocket.CLOSED) return + this.readyState = TunnelWebSocket.CLOSING + this.bridge.disconnectSocket(this, code, reason) + } + + accept(protocol?: string): void { + if (this.readyState !== TunnelWebSocket.CONNECTING) return + this.protocol = protocol ?? "" + this.readyState = TunnelWebSocket.OPEN + const event = new Event("open") + this.dispatchEvent(event) + this.onopen?.(event) + } + + receive(data: Uint8Array, binary: boolean): void { + if (this.readyState !== TunnelWebSocket.OPEN) return + const payload = binary + ? this.binaryType === "arraybuffer" + ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + : new Blob([new Uint8Array(data).buffer]) + : new TextDecoder().decode(data) + const event = new MessageEvent("message", { data: payload, origin: new URL(this.url).origin }) + this.dispatchEvent(event) + this.onmessage?.(event) + } + + fail(): void { + if (this.readyState === TunnelWebSocket.CLOSED) return + const event = new Event("error") + this.dispatchEvent(event) + this.onerror?.(event) + } + + finish(code = 1005, reason = "", wasClean = true): void { + if (this.readyState === TunnelWebSocket.CLOSED) return + this.readyState = TunnelWebSocket.CLOSED + const event = new CloseEvent("close", { code, reason, wasClean }) + this.dispatchEvent(event) + this.onclose?.(event) + } +} + +export function tunnelAwareWebSocket( + NativeWebSocket: typeof WebSocket, + bridge: RemoteSocketBridge, + shouldTunnel: (url: URL) => boolean, +): typeof WebSocket { + return new Proxy(NativeWebSocket, { + get(target, property, receiver) { + if (property === Symbol.hasInstance) { + return (value: unknown) => value instanceof TunnelWebSocket || value instanceof target + } + return Reflect.get(target, property, receiver) + }, + construct(target, args, newTarget) { + if (!args.length) return Reflect.construct(target, args, newTarget) + const url = new URL(args[0] as string | URL, window.location.href) + if (shouldTunnel(url)) return new TunnelWebSocket(url, args[1] as string | string[] | undefined, bridge) + return Reflect.construct(target, args, newTarget) + }, + }) +} + +function normalizeProtocols(value: string | string[] | undefined): string[] { + const protocols = value === undefined ? [] : typeof value === "string" ? [value] : [...value] + if (new Set(protocols).size !== protocols.length + || protocols.some((protocol) => !/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(protocol))) { + throw new DOMException("Invalid WebSocket protocol", "SyntaxError") + } + return protocols +} + +function validateClose(code: number | undefined, reason: string): void { + if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException("Invalid WebSocket close code", "InvalidAccessError") + } + if (new TextEncoder().encode(reason).byteLength > 123) { + throw new DOMException("WebSocket close reason is too long", "SyntaxError") + } +} diff --git a/packages/ui/src/renderer/index.html b/packages/ui/src/renderer/index.html index dd372bace..e156885dc 100644 --- a/packages/ui/src/renderer/index.html +++ b/packages/ui/src/renderer/index.html @@ -27,6 +27,6 @@
- + diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 6ddd8b9a2..f34c15d0b 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -112,6 +112,7 @@ declare global { interface Window { __CODENOMAD_API_BASE__?: string __CODENOMAD_EVENTS_URL__?: string + __CODENOMAD_REMOTE_CONTROL__?: { tunnelPath: string } __CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web" __CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote" | "preferences" readonly __CODENOMAD_WINDOW_ID__?: string | null From e4ef1849020e926f60c5f13b3d04ebd1e1cc25b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 4 Sep 2026 03:39:26 +0200 Subject: [PATCH 3/9] test(server): isolate automation registry paths across platforms Use the platform-specific automation registry environment variable in Developer Mode tests instead of assuming LOCALAPPDATA always controls the registry path. Derive the stale-registry fixture directory through the production resolver so Linux, macOS, and Windows exercise the same location policy. This prevents Linux CI from writing fixtures into an uncreated Windows-style path and keeps test registrations out of the runner's real user config. Validated with the focused automation suite, the complete server test suite, server typechecking, and git diff checks. --- .../src/opencode/automation-plugin.test.ts | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/server/src/opencode/automation-plugin.test.ts b/packages/server/src/opencode/automation-plugin.test.ts index 8dbc2b76c..acebf9628 100644 --- a/packages/server/src/opencode/automation-plugin.test.ts +++ b/packages/server/src/opencode/automation-plugin.test.ts @@ -6,6 +6,7 @@ import path from "node:path" import test from "node:test" import { AUTOMATION_BRIDGE_PATH, + automationBridgeDirectory, automationBridgeDirectories, createAutomationBridgeRegistration, parseDeveloperAction, @@ -50,6 +51,16 @@ function closeServer(server: http.Server | undefined): Promise { return new Promise((resolve) => server?.close(() => resolve()) ?? resolve()) } +function useTemporaryAutomationBridgeRoot(root: string): () => void { + const key = process.platform === "win32" ? "LOCALAPPDATA" : "XDG_RUNTIME_DIR" + const previous = process.env[key] + process.env[key] = root + return () => { + if (previous === undefined) delete process.env[key] + else process.env[key] = previous + } +} + test("validates Developer Mode actions", () => { assert.deepEqual(parseDeveloperAction({ action: "type", ref: "e4", text: "CodeNomad" }), { action: "type", @@ -100,8 +111,7 @@ test("removes only the generated legacy global plugin shim", async () => { test("restart waits for a new native generation and returns a fresh inspection", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-restart-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreAutomationBridgeRoot = useTemporaryAutomationBridgeRoot(root) const definitions: ToolDefinition[] = [] let removeOld: (() => Promise) | undefined let removeNew: (() => Promise) | undefined @@ -162,16 +172,14 @@ test("restart waits for a new native generation and returns a fresh inspection", await closeServer(newServer) await closeServer(preexistingServer) await Promise.all(distractorServers.map(closeServer)) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreAutomationBridgeRoot() await rm(root, { recursive: true, force: true }) } }) test("keeps inspected targets isolated per plugin setup", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-isolation-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreAutomationBridgeRoot = useTemporaryAutomationBridgeRoot(root) let removeBridge: (() => Promise) | undefined let server: http.Server | undefined try { @@ -190,16 +198,14 @@ test("keeps inspected targets isolated per plugin setup", async () => { } finally { await removeBridge?.() await closeServer(server) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreAutomationBridgeRoot() await rm(root, { recursive: true, force: true }) } }) test("pins parallel sessions to their independently inspected bridges", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-sessions-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreAutomationBridgeRoot = useTemporaryAutomationBridgeRoot(root) const removals: Array<() => Promise> = [] const servers: http.Server[] = [] try { @@ -220,16 +226,14 @@ test("pins parallel sessions to their independently inspected bridges", async () } finally { await Promise.all(removals.map((remove) => remove())) await Promise.all(servers.map(closeServer)) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreAutomationBridgeRoot() await rm(root, { recursive: true, force: true }) } }) test("prunes stale registry pressure before limiting discovery", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-stale-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreAutomationBridgeRoot = useTemporaryAutomationBridgeRoot(root) let removeBridge: (() => Promise) | undefined let server: http.Server | undefined try { @@ -238,7 +242,7 @@ test("prunes stale registry pressure before limiting discovery", async () => { : { result: { target: { id: "live", title: "Live", url: "http://app.test" }, nodes: [], diagnostics: [] } }) server = bridge.server removeBridge = await publishAutomationBridge(createAutomationBridgeRegistration(bridge.url)) - const directory = path.join(root, "CodeNomad", "automation-bridges") + const directory = automationBridgeDirectory() const base = Date.now() + 10_000 for (let index = 0; index < 70; index += 1) { const startedAt = base + index @@ -257,8 +261,7 @@ test("prunes stale registry pressure before limiting discovery", async () => { } finally { await removeBridge?.() await closeServer(server) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreAutomationBridgeRoot() await rm(root, { recursive: true, force: true }) } }) From 1468fe0c52486b58f33a47c81b321995db574cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 4 Sep 2026 04:03:48 +0200 Subject: [PATCH 4/9] fix(packaging): stage workspace runtime artifacts safely Strip lifecycle scripts from prebuilt internal workspace packages before the desktop production install. npm 10 can otherwise run a workspace prepare hook despite the staged npm ci using --ignore-scripts, while the intentionally minimal runtime staging directory has no TypeScript sources or tsconfig. Skip nested node_modules lock entries when copying workspace packages and fail clearly when a required prebuilt dist artifact is missing. Add a packaging invariant test and validate the full staging path with npm 10.8.2 for win32-x64. --- scripts/desktop-server-resources.cjs | 23 ++++++++++++++------ scripts/desktop-server-resources.test.cjs | 26 ++++++++++++++++++++++- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/scripts/desktop-server-resources.cjs b/scripts/desktop-server-resources.cjs index b49867b80..3d8b4374a 100644 --- a/scripts/desktop-server-resources.cjs +++ b/scripts/desktop-server-resources.cjs @@ -66,6 +66,20 @@ function validateServerProductionLock(lock) { return visited } +function stagePrebuiltWorkspacePackage(source, destination) { + const sourceManifest = path.join(source, "package.json") + const sourceDist = path.join(source, "dist") + if (!fs.existsSync(sourceDist)) { + throw new Error(`Missing prebuilt workspace artifact: ${sourceDist}`) + } + + const manifest = JSON.parse(fs.readFileSync(sourceManifest, "utf8")) + delete manifest.scripts + fs.mkdirSync(destination, { recursive: true }) + fs.writeFileSync(path.join(destination, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`) + fs.cpSync(sourceDist, path.join(destination, "dist"), { recursive: true }) +} + function stagePackagedServer(options) { const { workspaceRoot, serverRoot, log = () => {}, env = process.env } = options const npmTarget = resolveNpmTarget(options.target || env.CODENOMAD_NODE_TARGET) @@ -80,14 +94,10 @@ function stagePackagedServer(options) { fs.copyFileSync(lockPath, path.join(stagingRoot, "package-lock.json")) fs.copyFileSync(path.join(serverRoot, "package.json"), path.join(stagedServerRoot, "package.json")) for (const packagePath of productionClosure) { - if (!packagePath.startsWith("packages/") || packagePath === "packages/server") continue + if (!packagePath.startsWith("packages/") || packagePath === "packages/server" || packagePath.includes("/node_modules/")) continue const source = path.join(workspaceRoot, packagePath) const destination = path.join(stagingRoot, packagePath) - fs.mkdirSync(destination, { recursive: true }) - fs.copyFileSync(path.join(source, "package.json"), path.join(destination, "package.json")) - if (fs.existsSync(path.join(source, "dist"))) { - fs.cpSync(path.join(source, "dist"), path.join(destination, "dist"), { recursive: true }) - } + stagePrebuiltWorkspacePackage(source, destination) } log(`installing production server dependencies from the workspace lock for ${npmTarget.target}`) @@ -336,6 +346,7 @@ function pruneKnownServerDependencies(root, log) { module.exports = { copyPackagedServerResources, resolveNpmTarget, + stagePrebuiltWorkspacePackage, stagePackagedServer, validateServerProductionLock, } diff --git a/scripts/desktop-server-resources.test.cjs b/scripts/desktop-server-resources.test.cjs index c26d436e2..e1060b352 100644 --- a/scripts/desktop-server-resources.test.cjs +++ b/scripts/desktop-server-resources.test.cjs @@ -3,7 +3,11 @@ const fs = require("node:fs") const os = require("node:os") const path = require("node:path") const test = require("node:test") -const { resolveNpmTarget, validateServerProductionLock } = require("./desktop-server-resources.cjs") +const { + resolveNpmTarget, + stagePrebuiltWorkspacePackage, + validateServerProductionLock, +} = require("./desktop-server-resources.cjs") const { resolveEsbuildExecutable } = require("../packages/tauri-app/scripts/prebuild.js") test("maps every supported desktop target to npm OS and CPU", () => { @@ -29,6 +33,26 @@ test("integrity-pins the full server production closure in the root lock", () => assert.ok(closure.has("packages/remote-control-protocol")) }) +test("stages prebuilt workspace packages without install lifecycle scripts", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-workspace-stage-")) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const source = path.join(root, "source") + const destination = path.join(root, "destination") + fs.mkdirSync(path.join(source, "dist"), { recursive: true }) + fs.writeFileSync(path.join(source, "package.json"), JSON.stringify({ + name: "@codenomad/example", + version: "1.0.0", + scripts: { prepare: "npm run build" }, + })) + fs.writeFileSync(path.join(source, "dist", "index.js"), "export {}\n") + + stagePrebuiltWorkspacePackage(source, destination) + + const manifest = JSON.parse(fs.readFileSync(path.join(destination, "package.json"), "utf8")) + assert.equal(manifest.scripts, undefined) + assert.equal(fs.readFileSync(path.join(destination, "dist", "index.js"), "utf8"), "export {}\n") +}) + test("resolves a macOS ARM64 esbuild binary nested under esbuild", (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-esbuild-")) t.after(() => fs.rmSync(root, { recursive: true, force: true })) From 096279e8fe15d028773ebfc570343ac1180787f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 4 Sep 2026 05:11:41 +0200 Subject: [PATCH 5/9] chore(opencode): refresh V2 beta contract Update the generated OpenCode client, protocol, and schema lock entries to beta-19059 so CodeNomad validates against the currently published V2 contract. Keep the rotation-boundary projection test representative of production by marking its synthetic event stream connected. The new client deliberately suppresses background refreshes while disconnected, and real multiplexed SSE delivery sets this state before dispatch. Validated the published contract delta, npm 10 lock compatibility, UI/server/Electron typechecks, 145 browser-conditioned UI integration tests, and the 369-test server suite. --- package-lock.json | 24 ++++++++++---------- packages/ui/src/stores/opencode-data.test.ts | 2 ++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index b836928c9..edd8bd3d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3472,13 +3472,13 @@ } }, "node_modules/@opencode-ai/client": { - "version": "0.0.0-beta-18999", - "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-18999.tgz", - "integrity": "sha512-zQK0wGcdIvbQVAL3rPihxMZHe4mvlHs5b9FbvPryJcvS7DyUnhfxr7pMo5CIP1D7i4HvYgA6eOgkq/8fImbkzg==", + "version": "0.0.0-beta-19059", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-19059.tgz", + "integrity": "sha512-AAfbhUxaIMJ0rq5yMraU0IeQlw46XcbSqCgLJwxprd1m0TOV1qWFRh8mTrW6Tb3rm0Gm760DAusL3ZT/4wodjg==", "license": "MIT", "dependencies": { - "@opencode-ai/protocol": "0.0.0-beta-18999", - "@opencode-ai/schema": "0.0.0-beta-18999" + "@opencode-ai/protocol": "0.0.0-beta-19059", + "@opencode-ai/schema": "0.0.0-beta-19059" }, "peerDependencies": { "effect": "4.0.0-rc.112", @@ -3494,19 +3494,19 @@ } }, "node_modules/@opencode-ai/protocol": { - "version": "0.0.0-beta-18999", - "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-18999.tgz", - "integrity": "sha512-jDoJc9O5QJGJ0rf0xgiWWBIKQ1uFqjo0IJ0+z3fklDrkloSFCXCUG543tqVFdD6dCVyUBVXxhXKxWAgn5GfEcA==", + "version": "0.0.0-beta-19059", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-19059.tgz", + "integrity": "sha512-puB0qFtrzw6JvRUSExOxWBHjxciN8o4NItuf50rxcaZ+jK+qQIC/slWwA7hByr3xUjLaSesewGX2sJUGK1en5g==", "license": "MIT", "dependencies": { - "@opencode-ai/schema": "0.0.0-beta-18999", + "@opencode-ai/schema": "0.0.0-beta-19059", "effect": "4.0.0-rc.112" } }, "node_modules/@opencode-ai/schema": { - "version": "0.0.0-beta-18999", - "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-18999.tgz", - "integrity": "sha512-T8s3qNZmCnU0y82eHgrUDsuVUPzkt/nKMrlBhDigC5WSqeIpzeBFh+jwpfuAm97nuheOtpUf5QlcC02lqsn06Q==", + "version": "0.0.0-beta-19059", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-19059.tgz", + "integrity": "sha512-+YpeP4iMWewjDZMS0FP1bcD+pWVAqvL9QwYciTTl6np1iE6GeaVZ06x7Q0dZxwVdA+4vXOYjigyeTp41c5L7Eg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", diff --git a/packages/ui/src/stores/opencode-data.test.ts b/packages/ui/src/stores/opencode-data.test.ts index dd1628c8b..9a94215d3 100644 --- a/packages/ui/src/stores/opencode-data.test.ts +++ b/packages/ui/src/stores/opencode-data.test.ts @@ -8,6 +8,7 @@ import { applyOpenCodeDataEvent, destroyOpenCodeData, getOpenCodeMessageRevision import { emptyLatestWindow } from "./message-v2/message-window.ts" import { getRootClient } from "./opencode-client.ts" import { sdkManager } from "../lib/sdk-manager.ts" +import { sseManager } from "../lib/sse-manager.ts" function deferred() { let resolve!: (value: T) => void @@ -925,6 +926,7 @@ describe("OpenCode data projection", () => { it("processes a rotation-boundary side-effect event exactly once", async () => { const instanceId = "opencode-data-single-side-effect" const sessionId = "session" + sseManager.seedStatus(instanceId, "connected") const client = getRootClient(instanceId) let reads = 0 ;(client.session as any).message = async ({ messageID }: { messageID: string }) => { From e334a6d3bde8767ba86223f89f28ee3885b1ec04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 5 Sep 2026 20:04:49 +0200 Subject: [PATCH 6/9] feat(remote-control): preserve direct access alongside encrypted relay Publish the existing staged amendment reviewed with the user's approval: restore explicit LAN listening, saved remote servers and isolated remote windows in Electron and Tauri without turning LAN into an automatic relay fallback. Retain the staged relay hardening for one-shot crypto acceptance, ordered counters, bounded frame/body queues and control responses, authenticated asset fallback, and bounded remote proxy shutdown. Keep the staged OpenCode dependency and projection-test alignment with the dev baseline. The combined amendment and gatekeeper fixes were exercised with server, UI, Electron, protocol, Worker and Wrangler E2E suites. Tauri passed all 142 tests on rerun after a timing-sensitive process election assertion failed under parallel load. The remaining gatekeeper corrections are recorded separately for reviewability. --- .github/workflows/pr-build.yml | 1 + dev-docs/architecture.md | 2 +- package-lock.json | 24 +- packages/cloudflare/README.md | 8 +- .../scripts/remote-control-e2e.test.ts | 5 +- packages/cloudflare/src/index.test.ts | 18 + packages/cloudflare/src/index.ts | 11 +- .../src/remote-control/host-object.ts | 250 ++-- .../src/remote-control/relay-messages.test.ts | 28 + .../src/remote-control/relay-messages.ts | 28 +- packages/electron-app/electron/main/ipc.ts | 15 + packages/electron-app/electron/main/main.ts | 109 +- .../main/multiwindow-lifecycle.test.ts | 57 + .../electron/main/multiwindow-lifecycle.ts | 35 +- .../electron/main/navigation-security.test.ts | 2 +- .../electron/main/process-manager.ts | 91 +- .../main/remote-window-registry.test.ts | 139 ++ .../electron/main/remote-window-registry.ts | 99 ++ .../electron/main/startup.test.ts | 13 +- .../electron-app/electron/main/startup.ts | 14 + .../electron-app/electron/preload/index.cjs | 2 + .../electron/preload/index.test.ts | 2 +- packages/electron-app/package.json | 2 +- .../remote-control-protocol/src/crypto.ts | 8 +- .../src/frame-budget.test.ts | 32 + .../src/frame-budget.ts | 33 + .../remote-control-protocol/src/index.test.ts | 43 + packages/remote-control-protocol/src/index.ts | 1 + .../remote-control-protocol/src/messages.ts | 1 + packages/server/src/api-types.ts | 54 +- packages/server/src/config/schema.ts | 1 + packages/server/src/index.ts | 111 +- .../src/remote-control/connector-protocol.ts | 86 +- .../src/remote-control/connector.test.ts | 30 +- .../server/src/remote-control/connector.ts | 104 +- packages/server/src/remote-control/manager.ts | 19 +- .../src/remote-control/relay-response.test.ts | 38 + .../src/remote-control/relay-response.ts | 86 ++ .../__tests__/listener-base-url.test.ts | 8 +- .../__tests__/network-addresses.test.ts | 94 ++ .../src/server/__tests__/remote-proxy.test.ts | 328 +++++ packages/server/src/server/http-server.ts | 35 +- .../server/src/server/listener-base-url.ts | 5 + .../server/src/server/network-addresses.ts | 128 ++ packages/server/src/server/remote-proxy.ts | 621 +++++++++ packages/server/src/server/routes/meta.ts | 25 + .../server/src/server/routes/remote-proxy.ts | 54 + .../src/server/routes/remote-servers.ts | 166 +++ packages/server/src/settings/migrate.ts | 5 + packages/server/src/shutdown.test.ts | 5 +- packages/server/src/shutdown.ts | 3 +- packages/tauri-app/src-tauri/build.rs | 2 + .../src-tauri/capabilities/main-window.json | 2 + .../capabilities/preferences-window.json | 4 +- .../remote-window-notifications.json | 15 + .../src-tauri/gen/schemas/acl-manifests.json | 2 +- .../src-tauri/gen/schemas/capabilities.json | 2 +- .../src-tauri/gen/schemas/desktop-schema.json | 24 + .../src-tauri/gen/schemas/windows-schema.json | 24 + .../needs_local_certificate_install.toml | 11 + .../autogenerated/open_remote_window.toml | 11 + .../tauri-app/src-tauri/src/cli_manager.rs | 139 +- packages/tauri-app/src-tauri/src/linux_tls.rs | 104 ++ packages/tauri-app/src-tauri/src/main.rs | 1218 +++++++++++++++-- packages/tauri-app/src-tauri/src/shutdown.rs | 51 +- .../tauri-app/src-tauri/src/shutdown_tests.rs | 19 + packages/tauri-app/src-tauri/tauri.conf.json | 3 +- .../src/components/folder-selection-view.tsx | 213 ++- .../src/components/remote-access-overlay.tsx | 520 +++++++ .../src/components/remote-server-dialog.tsx | 80 ++ .../ui/src/components/settings-screen.tsx | 7 + .../remote-access-settings-section.tsx | 487 +++++++ .../remote-control-settings-section.tsx | 3 +- .../settings/saved-remote-servers-card.tsx | 67 + packages/ui/src/lib/api-client.ts | 19 + .../lib/hooks/use-remote-server-profiles.ts | 77 ++ .../lib/i18n/messages/de/folderSelection.ts | 31 + packages/ui/src/lib/i18n/messages/de/index.ts | 2 + .../src/lib/i18n/messages/de/remoteAccess.ts | 53 + .../ui/src/lib/i18n/messages/de/settings.ts | 6 +- .../lib/i18n/messages/en/folderSelection.ts | 31 + packages/ui/src/lib/i18n/messages/en/index.ts | 2 + .../src/lib/i18n/messages/en/remoteAccess.ts | 53 + .../ui/src/lib/i18n/messages/en/settings.ts | 6 +- .../lib/i18n/messages/es/folderSelection.ts | 31 + packages/ui/src/lib/i18n/messages/es/index.ts | 2 + .../src/lib/i18n/messages/es/remoteAccess.ts | 53 + .../ui/src/lib/i18n/messages/es/settings.ts | 6 +- .../lib/i18n/messages/fr/folderSelection.ts | 31 + packages/ui/src/lib/i18n/messages/fr/index.ts | 2 + .../src/lib/i18n/messages/fr/remoteAccess.ts | 53 + .../ui/src/lib/i18n/messages/fr/settings.ts | 6 +- .../lib/i18n/messages/he/folderSelection.ts | 31 + packages/ui/src/lib/i18n/messages/he/index.ts | 2 + .../src/lib/i18n/messages/he/remoteAccess.ts | 53 + .../ui/src/lib/i18n/messages/he/settings.ts | 6 +- .../lib/i18n/messages/ja/folderSelection.ts | 31 + packages/ui/src/lib/i18n/messages/ja/index.ts | 2 + .../src/lib/i18n/messages/ja/remoteAccess.ts | 53 + .../ui/src/lib/i18n/messages/ja/settings.ts | 6 +- .../lib/i18n/messages/ne/folderSelection.ts | 31 + packages/ui/src/lib/i18n/messages/ne/index.ts | 2 + .../src/lib/i18n/messages/ne/remoteAccess.ts | 53 + .../ui/src/lib/i18n/messages/ne/settings.ts | 6 +- .../lib/i18n/messages/ru/folderSelection.ts | 31 + packages/ui/src/lib/i18n/messages/ru/index.ts | 2 + .../src/lib/i18n/messages/ru/remoteAccess.ts | 53 + .../ui/src/lib/i18n/messages/ru/settings.ts | 6 +- .../lib/i18n/messages/tr/folderSelection.ts | 31 + packages/ui/src/lib/i18n/messages/tr/index.ts | 2 + .../src/lib/i18n/messages/tr/remoteAccess.ts | 50 + .../ui/src/lib/i18n/messages/tr/settings.ts | 6 +- .../i18n/messages/zh-Hans/folderSelection.ts | 31 + .../ui/src/lib/i18n/messages/zh-Hans/index.ts | 2 + .../lib/i18n/messages/zh-Hans/remoteAccess.ts | 53 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 6 +- packages/ui/src/lib/native/remote-window.ts | 70 + .../src/lib/remote-access-addresses.test.ts | 17 + .../ui/src/lib/remote-access-addresses.ts | 14 + .../lib/remote-control/bounded-body.test.ts | 30 + .../ui/src/lib/remote-control/bounded-body.ts | 23 + .../lib/remote-control/event-source.test.ts | 35 + .../ui/src/lib/remote-control/event-source.ts | 78 +- packages/ui/src/lib/remote-control/tunnel.ts | 201 ++- .../src/lib/remote-control/web-socket.test.ts | 36 + .../ui/src/lib/remote-control/web-socket.ts | 25 +- packages/ui/src/lib/runtime-env.test.ts | 5 +- packages/ui/src/lib/runtime-env.ts | 2 + packages/ui/src/stores/opencode-data.test.ts | 2 - packages/ui/src/stores/preferences.tsx | 110 +- .../src/styles/components/remote-access.css | 346 +++++ packages/ui/src/styles/controls.css | 1 + packages/ui/src/types/global.d.ts | 10 +- 133 files changed, 7679 insertions(+), 426 deletions(-) create mode 100644 packages/cloudflare/src/remote-control/relay-messages.test.ts create mode 100644 packages/electron-app/electron/main/remote-window-registry.test.ts create mode 100644 packages/electron-app/electron/main/remote-window-registry.ts create mode 100644 packages/remote-control-protocol/src/frame-budget.test.ts create mode 100644 packages/remote-control-protocol/src/frame-budget.ts create mode 100644 packages/server/src/remote-control/relay-response.test.ts create mode 100644 packages/server/src/remote-control/relay-response.ts create mode 100644 packages/server/src/server/__tests__/network-addresses.test.ts create mode 100644 packages/server/src/server/__tests__/remote-proxy.test.ts create mode 100644 packages/server/src/server/network-addresses.ts create mode 100644 packages/server/src/server/remote-proxy.ts create mode 100644 packages/server/src/server/routes/remote-proxy.ts create mode 100644 packages/server/src/server/routes/remote-servers.ts create mode 100644 packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json create mode 100644 packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml create mode 100644 packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml create mode 100644 packages/tauri-app/src-tauri/src/linux_tls.rs create mode 100644 packages/ui/src/components/remote-access-overlay.tsx create mode 100644 packages/ui/src/components/remote-server-dialog.tsx create mode 100644 packages/ui/src/components/settings/remote-access-settings-section.tsx create mode 100644 packages/ui/src/components/settings/saved-remote-servers-card.tsx create mode 100644 packages/ui/src/lib/hooks/use-remote-server-profiles.ts create mode 100644 packages/ui/src/lib/i18n/messages/de/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/en/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/es/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/he/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/ne/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/tr/remoteAccess.ts create mode 100644 packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts create mode 100644 packages/ui/src/lib/native/remote-window.ts create mode 100644 packages/ui/src/lib/remote-access-addresses.test.ts create mode 100644 packages/ui/src/lib/remote-access-addresses.ts create mode 100644 packages/ui/src/lib/remote-control/bounded-body.test.ts create mode 100644 packages/ui/src/lib/remote-control/bounded-body.ts create mode 100644 packages/ui/src/styles/components/remote-access.css diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index e09be4233..69505bd9c 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -141,6 +141,7 @@ jobs: packages/ui/src/lib/message-selection-position.test.ts packages/ui/src/lib/model-visibility.test.ts packages/ui/src/lib/runtime-env.test.ts + packages/ui/src/lib/remote-control/bounded-body.test.ts packages/ui/src/lib/remote-control/event-source.test.ts packages/ui/src/lib/remote-control/web-socket.test.ts packages/ui/src/lib/trailing-resync.test.ts diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 381119232..14688a022 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -44,7 +44,7 @@ CodeNomad listens only on `127.0.0.1`. Remote Control is an outbound-only connec The persistent host identity and P-256 key pair are stored in `remote-control.json` with restricted permissions where supported; legacy identities gain a key pair without changing their host ID or relay secret. The connector authenticates with a bearer secret, while browsers pair through a fragment-token link that expires after ten minutes and pins the host public key. The relay stores only token hashes, issues secure host-scoped device cookies for 30 days, and supports revocation. Remote credentials are stripped only after host-side decryption; the local connector injects a dedicated internal CodeNomad session instead. -Protocol v2 carries HTTP streams and WebSocket messages inside an end-to-end encrypted browser-to-host tunnel. An ephemeral browser P-256 key, a fresh host challenge, ECDH, and HKDF-SHA-256 produce directional AES-256-GCM keys; authenticated counters reject tampering, reordering, and replay across the same or later tunnels. Cloudflare sees host/device routing, sizes, and timing, but receives neither application plaintext nor the host private key. This protects against an honest-but-curious relay and captured tunnel traffic, not an actively malicious Worker operator that replaces the browser bundle before it runs; reviewed releases and Cloudflare account security remain in the trust boundary. Host and remote-client sockets use the Durable Objects WebSocket Hibernation API, with attachment metadata sufficient to recover routing after an object is evicted. Connector heartbeats use Cloudflare's automatic WebSocket response path so idle hosts stay reachable without waking the object. The relay and connector bound clients, requests, sockets, devices, pairing links, bodies, frames, and queues; stream HTTP responses with idle timeouts; cancel abandoned work; and reject stale responses after a host reconnect. Electron and Tauri keep the backend alive after the final window closes only while Remote Control is enabled. +Protocol v2 carries HTTP streams and WebSocket messages inside an end-to-end encrypted browser-to-host tunnel. An ephemeral browser P-256 key, a fresh host challenge, ECDH, and HKDF-SHA-256 produce directional AES-256-GCM keys; authenticated counters reject tampering, reordering, and replay across the same or later tunnels. Cloudflare sees host/device routing, sizes, and timing, but receives neither application plaintext nor the host private key. This protects against an honest-but-curious relay and captured tunnel traffic, not an actively malicious Worker operator that replaces the browser bundle before it runs; reviewed releases and Cloudflare account security remain in the trust boundary. Host and remote-client sockets use the Durable Objects WebSocket Hibernation API, with attachment metadata sufficient to recover routing after an object is evicted. Connector heartbeats use Cloudflare's automatic WebSocket response path so idle hosts stay reachable without waking the object. The relay, browser, and connector bound clients, requests, sockets, devices, pairing links, bodies, frames, encrypted queues, unread response data, and outbound buffers; stream HTTP responses with idle timeouts; cancel abandoned work; and reject stale responses after a host reconnect. Electron and Tauri keep the backend alive after the final window closes only while Remote Control is enabled. ## API Boundaries diff --git a/package-lock.json b/package-lock.json index edd8bd3d0..b836928c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3472,13 +3472,13 @@ } }, "node_modules/@opencode-ai/client": { - "version": "0.0.0-beta-19059", - "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-19059.tgz", - "integrity": "sha512-AAfbhUxaIMJ0rq5yMraU0IeQlw46XcbSqCgLJwxprd1m0TOV1qWFRh8mTrW6Tb3rm0Gm760DAusL3ZT/4wodjg==", + "version": "0.0.0-beta-18999", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-18999.tgz", + "integrity": "sha512-zQK0wGcdIvbQVAL3rPihxMZHe4mvlHs5b9FbvPryJcvS7DyUnhfxr7pMo5CIP1D7i4HvYgA6eOgkq/8fImbkzg==", "license": "MIT", "dependencies": { - "@opencode-ai/protocol": "0.0.0-beta-19059", - "@opencode-ai/schema": "0.0.0-beta-19059" + "@opencode-ai/protocol": "0.0.0-beta-18999", + "@opencode-ai/schema": "0.0.0-beta-18999" }, "peerDependencies": { "effect": "4.0.0-rc.112", @@ -3494,19 +3494,19 @@ } }, "node_modules/@opencode-ai/protocol": { - "version": "0.0.0-beta-19059", - "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-19059.tgz", - "integrity": "sha512-puB0qFtrzw6JvRUSExOxWBHjxciN8o4NItuf50rxcaZ+jK+qQIC/slWwA7hByr3xUjLaSesewGX2sJUGK1en5g==", + "version": "0.0.0-beta-18999", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-18999.tgz", + "integrity": "sha512-jDoJc9O5QJGJ0rf0xgiWWBIKQ1uFqjo0IJ0+z3fklDrkloSFCXCUG543tqVFdD6dCVyUBVXxhXKxWAgn5GfEcA==", "license": "MIT", "dependencies": { - "@opencode-ai/schema": "0.0.0-beta-19059", + "@opencode-ai/schema": "0.0.0-beta-18999", "effect": "4.0.0-rc.112" } }, "node_modules/@opencode-ai/schema": { - "version": "0.0.0-beta-19059", - "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-19059.tgz", - "integrity": "sha512-+YpeP4iMWewjDZMS0FP1bcD+pWVAqvL9QwYciTTl6np1iE6GeaVZ06x7Q0dZxwVdA+4vXOYjigyeTp41c5L7Eg==", + "version": "0.0.0-beta-18999", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-18999.tgz", + "integrity": "sha512-T8s3qNZmCnU0y82eHgrUDsuVUPzkt/nKMrlBhDigC5WSqeIpzeBFh+jwpfuAm97nuheOtpUf5QlcC02lqsn06Q==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index b6f57c540..dac72d824 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -39,9 +39,11 @@ operations consult the host object. Each host object limits browser tunnels, paired devices, unredeemed pairing links, pairing bodies, and opaque frame sizes. The local connector separately limits concurrent HTTP requests, local WebSockets, decrypted request bodies, -handshake frames, and pre-open socket queues. HTTP streams have an idle timeout -and abandoned requests are cancelled at the host. Expired pairing and device -records are removed by a Durable Object alarm. +handshake frames, encrypted send/receive queues, relay buffers, and pre-open +socket queues. The browser bounds request-body reads, active requests and +sockets, encrypted queues, WebSocket sends, and unread HTTP response data. +HTTP streams have an idle timeout and abandoned requests are cancelled at the +host. Expired pairing and device records are removed by a Durable Object alarm. These limits contain abuse and memory use, but they are not a substitute for Cloudflare account-level usage alerts and limits. diff --git a/packages/cloudflare/scripts/remote-control-e2e.test.ts b/packages/cloudflare/scripts/remote-control-e2e.test.ts index aeeb8487f..a5fa466a3 100644 --- a/packages/cloudflare/scripts/remote-control-e2e.test.ts +++ b/packages/cloudflare/scripts/remote-control-e2e.test.ts @@ -119,7 +119,10 @@ test("hibernating relay carries opaque HTTP streams and WebSockets end to end", /failed|timed out/i, ) - for (let index = 0; index < 8; index += 1) await manager.createPairing() + const pairingResults = await Promise.allSettled( + Array.from({ length: 8 }, () => manager.createPairing()), + ) + assert.equal(pairingResults.filter((result) => result.status === "fulfilled").length, 8) await assert.rejects(() => manager.createPairing(), /Too many active pairing links/) const devices = await manager.devices() diff --git a/packages/cloudflare/src/index.test.ts b/packages/cloudflare/src/index.test.ts index 8715122e6..9bfbe2c05 100644 --- a/packages/cloudflare/src/index.test.ts +++ b/packages/cloudflare/src/index.test.ts @@ -78,6 +78,24 @@ test("remote HTML is not served before device authentication", async () => { assert.equal(assetRequests, 0) }) +test("asset fallback HTML still requires device authentication", async () => { + const hostId = "2".repeat(32) + let hostRequests = 0 + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/missing-route`, { headers: { accept: "*/*", cookie: DEVICE_COOKIE } }), + relayEnv( + () => { + hostRequests += 1 + return Response.json({ error: "Remote device is not paired" }, { status: 401 }) + }, + () => new Response("", { headers: { "content-type": "text/html" } }), + ), + ) + assert.equal(response.status, 401) + assert.equal(hostRequests, 1) + assert.doesNotMatch(await response.text(), /doctype/) +}) + test("immutable UI assets do not wake the host object", async () => { const hostId = "1".repeat(32) let hostRequests = 0 diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index d2776d15a..3987d65e7 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -96,11 +96,12 @@ async function handleRemoteHost(request: Request, env: Env, hostId: string): Pro if (!authorized.ok) return authorized return Response.json({ error: "Encrypted Remote Control tunnel required" }, { status: 426 }) } - if (isHtmlNavigation(request, url)) { + const htmlNavigation = isHtmlNavigation(request, url) + if (htmlNavigation) { const authorized = await checkRemoteSession(request, env, hostId) if (!authorized.ok) return authorized } - return remoteAsset(request, env) + return remoteAsset(request, env, hostId, htmlNavigation) } function checkRemoteSession(request: Request, env: Env, hostId: string): Promise { @@ -124,7 +125,7 @@ function isHtmlNavigation(request: Request, url: URL): boolean { || request.headers.get("accept")?.includes("text/html") === true } -async function remoteAsset(request: Request, env: Env): Promise { +async function remoteAsset(request: Request, env: Env, hostId: string, authorized: boolean): Promise { const response = await env.ASSETS.fetch(request) const headers = new Headers(response.headers) headers.set("Referrer-Policy", "no-referrer") @@ -137,6 +138,10 @@ async function remoteAsset(request: Request, env: Env): Promise { if (!isHtml) { return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) } + if (!authorized) { + const authorization = await checkRemoteSession(request, env, hostId) + if (!authorization.ok) return authorization + } if (request.method === "HEAD") { return new Response(null, { status: response.status, statusText: response.statusText, headers }) } diff --git a/packages/cloudflare/src/remote-control/host-object.ts b/packages/cloudflare/src/remote-control/host-object.ts index e97eda24f..5eb794cce 100644 --- a/packages/cloudflare/src/remote-control/host-object.ts +++ b/packages/cloudflare/src/remote-control/host-object.ts @@ -132,28 +132,24 @@ export class RemoteControlHost implements DurableObject { async alarm(): Promise { const now = Date.now() - const pairings = await this.state.storage.list({ prefix: PAIRING_PREFIX }) - const devices = await this.state.storage.list({ prefix: DEVICE_PREFIX }) - const expiredDevices = Array.from(devices.entries()).filter(([, record]) => record.expiresAt <= now) - const expired = [ - ...Array.from(pairings.entries()).filter(([, record]) => record.expiresAt <= now).map(([key]) => key), - ...expiredDevices.map(([key]) => key), - ] - if (expired.length) await this.state.storage.delete(expired) - const expiredDeviceIds = new Set(expiredDevices.map(([, record]) => record.id)) - if (expiredDeviceIds.size) { - for (const socket of this.state.getWebSockets(CLIENT_TAG)) { - const attachment = socketAttachment(socket) - if (attachment?.role === "client" && expiredDeviceIds.has(attachment.deviceId)) { - this.closeClient(attachment.id, 1008, "Remote device expired") - } - } - } - const nextExpiration = [...pairings.values(), ...devices.values()] - .map((record) => record.expiresAt) - .filter((expiresAt) => expiresAt > now) - .sort((left, right) => left - right)[0] - if (nextExpiration) await this.state.storage.setAlarm(nextExpiration) + const expiredDeviceIds = await this.state.storage.transaction(async (transaction) => { + const pairings = await transaction.list({ prefix: PAIRING_PREFIX }) + const devices = await transaction.list({ prefix: DEVICE_PREFIX }) + const expiredDevices = Array.from(devices.entries()).filter(([, record]) => record.expiresAt <= now) + const expired = [ + ...Array.from(pairings.entries()).filter(([, record]) => record.expiresAt <= now).map(([key]) => key), + ...expiredDevices.map(([key]) => key), + ] + if (expired.length) await transaction.delete(expired) + const nextExpiration = [...pairings.values(), ...devices.values()] + .map((record) => record.expiresAt) + .filter((expiresAt) => expiresAt > now) + .sort((left, right) => left - right)[0] + if (nextExpiration) await transaction.setAlarm(nextExpiration) + else await transaction.deleteAlarm() + return expiredDevices.map(([, record]) => record.id) + }) + this.closeDeviceSockets(expiredDeviceIds, "Remote device expired") } private async connectHost(request: Request): Promise { @@ -211,24 +207,41 @@ export class RemoteControlHost implements DurableObject { private async createPairing(request: Request): Promise { if (request.method !== "POST") return new Response(null, { status: 405 }) if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) - const host = this.hostConnection() - if (!host?.attachment.ready) return Response.json({ error: "Host is offline" }, { status: 409 }) + if (!this.isHostConnected()) return Response.json({ error: "Host is offline" }, { status: 409 }) - const pairings = await this.state.storage.list({ prefix: PAIRING_PREFIX }) const now = Date.now() - const expired = Array.from(pairings.entries()).filter(([, record]) => record.expiresAt <= now).map(([key]) => key) - if (expired.length) await this.state.storage.delete(expired) - if (pairings.size - expired.length >= MAX_ACTIVE_PAIRINGS) { - return Response.json({ error: "Too many active pairing links" }, { status: 429 }) - } - const token = randomToken() + const key = `${PAIRING_PREFIX}${await tokenHash(token)}` const expiresAt = now + PAIRING_TTL_MS - await this.state.storage.put(`${PAIRING_PREFIX}${await tokenHash(token)}`, { - expiresAt, - connectionId: host.attachment.connectionId, - } satisfies PairingRecord) - await this.scheduleExpirationCleanup(expiresAt) + const host = this.hostConnection() + if (!host?.attachment.ready) return Response.json({ error: "Host reconnected while creating the pairing link" }, { status: 409 }) + const connectionId = host.attachment.connectionId + const created = await this.state.storage.transaction(async (transaction) => { + const pairings = await transaction.list({ prefix: PAIRING_PREFIX }) + const expired = Array.from(pairings.entries()) + .filter(([, record]) => record.expiresAt <= now) + .map(([pairingKey]) => pairingKey) + if (expired.length) await transaction.delete(expired) + const active = Array.from(pairings.values()) + .filter((record) => record.expiresAt > now && record.connectionId === connectionId) + if (active.length >= MAX_ACTIVE_PAIRINGS) return false + await transaction.put(key, { + expiresAt, + connectionId, + } satisfies PairingRecord) + await scheduleExpiration(transaction, expiresAt) + return true + }) + if (!created) { + return Response.json({ error: "Too many active pairing links" }, { status: 429 }) + } + if (!this.isCurrentHost(connectionId)) { + await this.state.storage.transaction(async (transaction) => { + const record = await transaction.get(key) + if (record?.connectionId === connectionId) await transaction.delete(key) + }) + return Response.json({ error: "Host reconnected while creating the pairing link" }, { status: 409 }) + } return Response.json({ token, expiresAt: new Date(expiresAt).toISOString() }) } @@ -239,23 +252,8 @@ export class RemoteControlHost implements DurableObject { const token = typeof input.token === "string" ? input.token.trim() : "" if (!RELAY_TOKEN_PATTERN.test(token)) return Response.json({ error: "Valid pairing token required" }, { status: 400 }) - const key = `${PAIRING_PREFIX}${await tokenHash(token)}` - const pairing = await this.state.storage.transaction(async (transaction) => { - const record = await transaction.get(key) - if (record) await transaction.delete(key) - return record - }) - const host = this.hostConnection() - if (!pairing || pairing.expiresAt <= Date.now() || !host?.attachment.ready || pairing.connectionId !== host.attachment.connectionId) { - return Response.json({ error: "Pairing link is invalid or expired" }, { status: 401 }) - } - - const records = await this.state.storage.list({ prefix: DEVICE_PREFIX }) + const pairingKey = `${PAIRING_PREFIX}${await tokenHash(token)}` const now = Date.now() - const expired = Array.from(records.entries()).filter(([, device]) => device.expiresAt <= now).map(([recordKey]) => recordKey) - if (expired.length) await this.state.storage.delete(expired) - if (records.size - expired.length >= MAX_DEVICES) return Response.json({ error: "Too many paired devices" }, { status: 429 }) - const deviceToken = randomToken() const device: DeviceRecord = { id: crypto.randomUUID(), @@ -264,8 +262,41 @@ export class RemoteControlHost implements DurableObject { lastSeenAt: now, expiresAt: now + DEVICE_TTL_MS, } - await this.state.storage.put(`${DEVICE_PREFIX}${await tokenHash(deviceToken)}`, device) - await this.scheduleExpirationCleanup(device.expiresAt) + const deviceKey = `${DEVICE_PREFIX}${await tokenHash(deviceToken)}` + const host = this.hostConnection() + const connectionId = host?.attachment.ready ? host.attachment.connectionId : null + const result = await this.state.storage.transaction(async (transaction) => { + const record = await transaction.get(pairingKey) + if (record) await transaction.delete(pairingKey) + if (!record || record.expiresAt <= now || !connectionId || record.connectionId !== connectionId) { + return { status: "invalid" as const, expiredDeviceIds: [] as string[] } + } + const devices = await transaction.list({ prefix: DEVICE_PREFIX }) + const expiredEntries = Array.from(devices.entries()).filter(([, candidate]) => candidate.expiresAt <= now) + if (expiredEntries.length) await transaction.delete(expiredEntries.map(([key]) => key)) + const expiredDeviceIds = expiredEntries.map(([, candidate]) => candidate.id) + if (devices.size - expiredEntries.length >= MAX_DEVICES) { + return { status: "full" as const, expiredDeviceIds } + } + await transaction.put(deviceKey, device) + await scheduleExpiration(transaction, device.expiresAt) + return { status: "created" as const, expiredDeviceIds } + }) + this.closeDeviceSockets(result.expiredDeviceIds, "Remote device expired") + if (result.status === "invalid") { + return Response.json({ error: "Pairing link is invalid or expired" }, { status: 401 }) + } + if (result.status === "full") { + return Response.json({ error: "Too many paired devices" }, { status: 429 }) + } + if (!connectionId || !this.isCurrentHost(connectionId)) { + await this.state.storage.transaction(async (transaction) => { + const stored = await transaction.get(deviceKey) + if (stored?.id === device.id) await transaction.delete(deviceKey) + }) + return Response.json({ error: "Pairing link is invalid or expired" }, { status: 401 }) + } + return new Response(null, { status: 204, headers: { "Set-Cookie": deviceCookie(deviceToken, Math.floor(DEVICE_TTL_MS / 1000)) }, @@ -274,35 +305,39 @@ export class RemoteControlHost implements DurableObject { private async devices(request: Request): Promise { if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) - const records = await this.state.storage.list({ prefix: DEVICE_PREFIX }) const now = Date.now() - const expired = Array.from(records.entries()).filter(([, device]) => device.expiresAt <= now).map(([key]) => key) - if (expired.length) await this.state.storage.delete(expired) - const devices = Array.from(records.values()) - .filter((device) => device.expiresAt > now) - .map((device) => ({ - id: device.id, - name: device.name, - createdAt: new Date(device.createdAt).toISOString(), - lastSeenAt: new Date(device.lastSeenAt).toISOString(), - })) - return Response.json({ devices }) + const result = await this.state.storage.transaction(async (transaction) => { + const records = await transaction.list({ prefix: DEVICE_PREFIX }) + const expiredEntries = Array.from(records.entries()).filter(([, device]) => device.expiresAt <= now) + if (expiredEntries.length) await transaction.delete(expiredEntries.map(([key]) => key)) + return { + expiredDeviceIds: expiredEntries.map(([, device]) => device.id), + devices: Array.from(records.values()) + .filter((device) => device.expiresAt > now) + .map((device) => ({ + id: device.id, + name: device.name, + createdAt: new Date(device.createdAt).toISOString(), + lastSeenAt: new Date(device.lastSeenAt).toISOString(), + })), + } + }) + this.closeDeviceSockets(result.expiredDeviceIds, "Remote device expired") + return Response.json({ devices: result.devices }) } private async revokeDevice(request: Request): Promise { if (request.method !== "DELETE") return new Response(null, { status: 405 }) if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) const deviceId = request.headers.get("x-codenomad-relay-device-id") - const records = await this.state.storage.list({ prefix: DEVICE_PREFIX }) - const entry = Array.from(records.entries()).find(([, device]) => device.id === deviceId) + const entry = await this.state.storage.transaction(async (transaction) => { + const records = await transaction.list({ prefix: DEVICE_PREFIX }) + const match = Array.from(records.entries()).find(([, device]) => device.id === deviceId) + if (match) await transaction.delete(match[0]) + return match?.[1] ?? null + }) if (entry) { - await this.state.storage.delete(entry[0]) - for (const socket of this.state.getWebSockets(CLIENT_TAG)) { - const attachment = socketAttachment(socket) - if (attachment?.role === "client" && attachment.deviceId === deviceId) { - this.closeClient(attachment.id, 1008, "Remote device was revoked") - } - } + this.closeDeviceSockets([entry.id], "Remote device was revoked") } return new Response(null, { status: 204 }) } @@ -387,13 +422,18 @@ export class RemoteControlHost implements DurableObject { } private closeClient(id: string, code: number, reason: string): void { - this.clientSocket(id)?.close(safeRelayCloseCode(code), reason.slice(0, 120)) + this.clientSocket(id)?.close(safeRelayCloseCode(code), boundedCloseReason(reason)) } private isHostConnected(): boolean { return this.hostConnection()?.attachment.ready === true } + private isCurrentHost(connectionId: string): boolean { + const host = this.hostConnection() + return host?.attachment.ready === true && host.attachment.connectionId === connectionId + } + private sendHost(message: RelayToHostMessage): boolean { const host = this.hostConnection() if (!host?.attachment.ready && message.type !== "ready") return false @@ -435,16 +475,22 @@ export class RemoteControlHost implements DurableObject { const token = cookieToken(request) if (!token || !RELAY_TOKEN_PATTERN.test(token)) return null const key = `${DEVICE_PREFIX}${await tokenHash(token)}` - const device = await this.state.storage.get(key) - if (!device || device.expiresAt <= Date.now()) { - await this.state.storage.delete(key) - return null - } - if (Date.now() - device.lastSeenAt > 60_000) { - device.lastSeenAt = Date.now() - await this.state.storage.put(key, device) - } - return device + const now = Date.now() + const result = await this.state.storage.transaction(async (transaction) => { + const device = await transaction.get(key) + if (!device) return { device: null, expiredDeviceId: null } + if (device.expiresAt <= now) { + await transaction.delete(key) + return { device: null, expiredDeviceId: device.id } + } + if (now - device.lastSeenAt > 60_000) { + device.lastSeenAt = now + await transaction.put(key, device) + } + return { device, expiredDeviceId: null } + }) + if (result.expiredDeviceId) this.closeDeviceSockets([result.expiredDeviceId], "Remote device expired") + return result.device } private unpairedResponse(): Response { @@ -454,12 +500,26 @@ export class RemoteControlHost implements DurableObject { }) } - private async scheduleExpirationCleanup(expiresAt: number): Promise { - const scheduled = await this.state.storage.getAlarm() - if (scheduled === null || expiresAt < scheduled) await this.state.storage.setAlarm(expiresAt) + private closeDeviceSockets(deviceIds: Iterable, reason: string): void { + const ids = new Set(deviceIds) + if (!ids.size) return + for (const socket of this.state.getWebSockets(CLIENT_TAG)) { + const attachment = socketAttachment(socket) + if (attachment?.role === "client" && ids.has(attachment.deviceId)) { + this.closeClient(attachment.id, 1008, reason) + } + } } } +async function scheduleExpiration( + transaction: DurableObjectTransaction, + expiresAt: number, +): Promise { + const scheduled = await transaction.getAlarm() + if (scheduled === null || expiresAt < scheduled) await transaction.setAlarm(expiresAt) +} + function clientTag(id: string): string { return `${CLIENT_TAG}:${id}` } @@ -469,3 +529,15 @@ function socketAttachment(socket: WebSocket): SocketAttachment | null { if (!value || (value.role !== "host" && value.role !== "client")) return null return value as SocketAttachment } + +function boundedCloseReason(value: string): string { + let result = "" + let bytes = 0 + for (const character of value) { + const next = new TextEncoder().encode(character).byteLength + if (bytes + next > 123) break + result += character + bytes += next + } + return result +} diff --git a/packages/cloudflare/src/remote-control/relay-messages.test.ts b/packages/cloudflare/src/remote-control/relay-messages.test.ts new file mode 100644 index 000000000..90c5afc9e --- /dev/null +++ b/packages/cloudflare/src/remote-control/relay-messages.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { parseHostMessage, readPairingInput } from "./relay-messages" + +const id = "123e4567-e89b-42d3-a456-426614174000" + +test("host relay messages require bounded identifiers and close metadata", () => { + assert.ok(parseHostMessage(JSON.stringify({ type: "tunnel.message", id, data: "", binary: true }))) + assert.equal(parseHostMessage(JSON.stringify({ type: "tunnel.message", id: "not-a-uuid", data: "", binary: true })), null) + assert.equal(parseHostMessage(JSON.stringify({ type: "tunnel.close", id, reason: 42 })), null) + assert.equal(parseHostMessage(JSON.stringify({ type: "tunnel.close", id, code: 1.5 })), null) + assert.equal(parseHostMessage(JSON.stringify({ type: "tunnel.close", id, reason: "🙂".repeat(31) })), null) +}) + +test("pairing input rejects excessive stream fragmentation", async () => { + let cancelled = false + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array()) + }, + cancel() { + cancelled = true + }, + }) + + assert.equal(await readPairingInput(new Request("https://relay.example", { method: "POST", body: stream, duplex: "half" } as RequestInit), 4096), null) + assert.equal(cancelled, true) +}) diff --git a/packages/cloudflare/src/remote-control/relay-messages.ts b/packages/cloudflare/src/remote-control/relay-messages.ts index b56ca9317..e9429a542 100644 --- a/packages/cloudflare/src/remote-control/relay-messages.ts +++ b/packages/cloudflare/src/remote-control/relay-messages.ts @@ -1,16 +1,22 @@ import type { HostToRelayMessage } from "@codenomad/remote-control-protocol" +const MESSAGE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const MAX_PAIRING_BODY_CHUNKS = 256 +const MAX_CLOSE_REASON_CHARS = 120 + export async function readPairingInput(request: Request, maxBytes: number): Promise<{ token?: unknown; name?: unknown } | null> { if (!request.body) return null const reader = request.body.getReader() const chunks: Uint8Array[] = [] let size = 0 + let reads = 0 while (true) { const { done, value } = await reader.read() if (done) break + reads += 1 size += value.byteLength - if (size > maxBytes) { - await reader.cancel() + if (size > maxBytes || reads > MAX_PAIRING_BODY_CHUNKS) { + await reader.cancel().catch(() => undefined) return null } chunks.push(value) @@ -33,8 +39,9 @@ export function parseHostMessage(value: string): HostToRelayMessage | null { try { const message = JSON.parse(value) as Partial if (message.type === "ready" && typeof message.protocol === "number") return message as HostToRelayMessage - if (message.type === "tunnel.close" && typeof message.id === "string") return message as HostToRelayMessage - if (message.type === "tunnel.message" && typeof message.id === "string" && typeof message.data === "string" && typeof message.binary === "boolean") { + if (!validMessageId((message as { id?: unknown }).id)) return null + if (message.type === "tunnel.close" && validCloseMetadata(message.code, message.reason)) return message as HostToRelayMessage + if (message.type === "tunnel.message" && typeof message.data === "string" && typeof message.binary === "boolean") { return message as HostToRelayMessage } return null @@ -51,6 +58,17 @@ export function base64ByteLength(value: string): number { export function safeRelayCloseCode(value: number): number { if (value === 1000 || value === 1001 || value === 1002 || value === 1003 - || (value >= 1007 && value <= 1014) || (value >= 3000 && value <= 4999)) return value + || (Number.isSafeInteger(value) && value >= 1007 && value <= 1014) + || (Number.isSafeInteger(value) && value >= 3000 && value <= 4999)) return value return 1011 } + +function validMessageId(value: unknown): value is string { + return typeof value === "string" && MESSAGE_ID_PATTERN.test(value) +} + +function validCloseMetadata(code: unknown, reason: unknown): boolean { + return (code === undefined || (typeof code === "number" && Number.isSafeInteger(code) && code >= 0 && code <= 0xffff)) + && (reason === undefined || (typeof reason === "string" && reason.length <= MAX_CLOSE_REASON_CHARS + && new TextEncoder().encode(reason).byteLength <= 123 && !/[\0\r\n]/.test(reason))) +} diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 771123e17..e6d3b955f 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -5,6 +5,7 @@ import type { DeveloperMode } from "./developer-mode" import type { CliProcessManager } from "./process-manager" import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open" import { popupTitlebarMenu, setWorkspaceMenuEnabled, type TitlebarMenu } from "./menu" +import { requireHttpUrl } from "./navigation-security" import { validateMainFrame } from "./ipc-security" interface LocalSender { @@ -16,6 +17,7 @@ interface CliIPCDependencies { resolveLocal(sender: IpcMainInvokeEvent["sender"]): LocalSender | undefined resolvePreferences?(sender: IpcMainInvokeEvent["sender"]): BrowserWindow | undefined getAllowedOrigins(window: BrowserWindow): string[] + openRemoteWindow(payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }): Promise newWindow(): Promise nextFolder(windowId: string): string | null acknowledgeFolder(windowId: string, folder: string, opened: boolean): void @@ -177,6 +179,19 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD anyTrusted(event) return { granted: await requestMicrophoneAccess() } }) + ipcMain.handle("remote:openWindow", async (event, payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }) => { + settings(event) + if (!payload || typeof payload.id !== "string" || !payload.id.trim() || typeof payload.name !== "string" || typeof payload.baseUrl !== "string" + || (payload.entryUrl !== undefined && typeof payload.entryUrl !== "string") + || (payload.proxySessionId !== undefined && typeof payload.proxySessionId !== "string") + || typeof payload.skipTlsVerify !== "boolean") { + throw new Error("Invalid remote window request") + } + requireHttpUrl(payload.baseUrl, "baseUrl") + if (payload.entryUrl !== undefined) requireHttpUrl(payload.entryUrl, "entryUrl") + await dependencies.openRemoteWindow(payload) + return { ok: true } + }) ipcMain.handle("notifications:show", async (event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { anyTrusted(event) if (!Notification.isSupported()) return { ok: false, reason: "unsupported" } diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index cafd95cfe..e6572f965 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -15,15 +15,15 @@ import { LocalWindowRegistry, type LocalWindowRecord } from "./local-window-regi import { clearWorkspaceMenuWindow, createApplicationMenu, setWorkspaceMenuEnabled } from "./menu" import { resolveFocusedLocalTarget, resolveWindowTarget } from "./menu-target" import { MultiwindowLifecycle } from "./multiwindow-lifecycle" -import { decideNavigation } from "./navigation-security" +import { decideNavigation, requireHttpUrl } from "./navigation-security" import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions" import { setupPreferencesIPC } from "./preferences-ipc" import { createPreferencesUrl, PreferencesWindowRegistry, type PreferencesRequest } from "./preferences-window" import { CliProcessManager } from "./process-manager" -import { navigateTrustedWindow } from "./window-navigation" +import { navigateRemoteWindow, RemoteWindowRegistry } from "./remote-window-registry" import { resolveConfiguredRendererOrigins } from "./renderer-origin" import { SerializedLifecycle } from "./serialized-lifecycle" -import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, parseLaunchIntent, prepareSecondLaunchIntent, resolveStorageScope, startPrimaryInstance, type LaunchIntent } from "./startup" +import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, prepareSecondLaunchIntent, resolveRemoteSessionPartition, resolveStorageScope, startPrimaryInstance, type LaunchIntent } from "./startup" import { clampWindowBounds, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, installWindowZoomInput, restoreWindowState, WindowStateTracker } from "./window-state" const mainDirname = dirname(fileURLToPath(import.meta.url)) @@ -111,10 +111,18 @@ function runPrimary(firstIntent: LaunchIntent) { requestRelaunch: () => lifecycle.requestRelaunch(), }) const cli = new CliProcessManager((method) => developerMode.handleNativeRequest(method)) - const windowOrigins = new Map>() + const remoteOrigins = new Map>() + const insecureOrigins = new Map>() const navigationLifecycle = new SerializedLifecycle() let backendUrl: string | null = null let backendTargetUrl: string | null = null + const remoteWindows = new RemoteWindowRegistry((sessionId) => { + if (!backendUrl) return + const target = new URL(`/api/remote-proxy/sessions/${encodeURIComponent(sessionId)}`, backendUrl) + const request = (target.protocol === "https:" ? https : http).request(target, { method: "DELETE" }, (response) => response.resume()) + request.on("error", (error) => console.warn("[electron] failed to clean up remote proxy session", sessionId, error)) + request.end() + }) const preferencesWindows = new PreferencesWindowRegistry() let pendingPreferencesRestore = clientState.preferences let preferencesNavigation: ClientStateNavigationController | null = null @@ -122,7 +130,7 @@ function runPrimary(firstIntent: LaunchIntent) { let preferencesTransitionId = 0 const getAllowedOrigins = (window?: BrowserWindow | null): string[] => { - const origins = new Set(windowOrigins.get(window?.id ?? -1) ?? []) + const origins = new Set(remoteOrigins.get(window?.id ?? -1) ?? []) for (const origin of resolveConfiguredRendererOrigins(backendUrl, app.isPackaged, [process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL])) origins.add(origin) return [...origins] } @@ -158,7 +166,7 @@ function runPrimary(firstIntent: LaunchIntent) { await (target.url ? window.loadURL(target.url) : window.loadFile(target.file!)) if (!record.navigation.isCurrent(generation)) return record.backendUrl = null - windowOrigins.delete(record.window.id) + remoteOrigins.delete(record.window.id) }).catch((error) => { if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load loading screen", error) }) @@ -169,18 +177,18 @@ function runPrimary(firstIntent: LaunchIntent) { try { origin = new URL(url).origin } catch { return } await record.navigation.navigate(async (window, generation) => { if (!record.navigation.isCurrent(generation)) return - const previous = windowOrigins.get(record.window.id) - windowOrigins.set(record.window.id, new Set([...(previous ?? []), origin])) + const previous = remoteOrigins.get(record.window.id) + remoteOrigins.set(record.window.id, new Set([...(previous ?? []), origin])) try { await window.loadURL(url) } catch (error) { if (record.navigation.isCurrent(generation)) { - if (previous) windowOrigins.set(record.window.id, previous); else windowOrigins.delete(record.window.id) + if (previous) remoteOrigins.set(record.window.id, previous); else remoteOrigins.delete(record.window.id) } throw error } if (!record.navigation.isCurrent(generation)) return record.loading = false record.backendUrl = url - windowOrigins.set(record.window.id, new Set([origin])) + remoteOrigins.set(record.window.id, new Set([origin])) }).catch((error) => { if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load backend", error) }) @@ -241,7 +249,8 @@ function runPrimary(firstIntent: LaunchIntent) { window.on("closed", () => { registry.remove(windowId) clearWorkspaceMenuWindow(webContentsId) - windowOrigins.delete(nativeWindowId) + remoteOrigins.delete(nativeWindowId) + insecureOrigins.delete(webContentsId) }) if (isMac) window.webContents.session.setSpellCheckerEnabled(false) if (process.env.NODE_ENV === "development") window.webContents.openDevTools({ mode: "detach" }) @@ -275,7 +284,7 @@ function runPrimary(firstIntent: LaunchIntent) { setupCliIPC(cli, { resolveLocal: (sender) => registry.resolve(sender), resolvePreferences: (sender) => preferencesWindows.resolve(sender), getAllowedOrigins, - newWindow: () => intentQueue.enqueue({ newWindow: true, folders: [] }), + openRemoteWindow, newWindow: () => intentQueue.enqueue({ newWindow: true, folders: [] }), nextFolder: (id) => registry.nextFolder(id), acknowledgeFolder: (id, folder, opened) => registry.acknowledgeFolder(id, folder, opened), developerMode, }) @@ -314,6 +323,12 @@ function runPrimary(firstIntent: LaunchIntent) { const intent = parseLaunchIntent([path], process.cwd()) if (intent.folders.length) void intentQueue.enqueue(intent).catch(() => {}) }) + app.on("certificate-error", (event, contents, url, error, _certificate, callback) => { + if (contents && isRemoteCertificateAllowed(contents.id, url, insecureOrigins)) { + event.preventDefault(); console.warn("[cli] allowing insecure remote certificate", url, error); callback(true) + } else callback(false) + }) + cli.on("bootstrapToken", (token) => bootstrap.setToken(token)) cli.on("ready", (status) => { if (!status.url) return @@ -372,6 +387,43 @@ function runPrimary(firstIntent: LaunchIntent) { const candidates = [join(process.resourcesPath, "preload/index.js"), join(mainDirname, "../preload/index.js"), join(mainDirname, "../preload/index.cjs"), join(mainDirname, "../../electron/preload/index.cjs"), join(app.getAppPath(), "electron/preload/index.cjs")] return candidates.find(existsSync) ?? candidates[0] } + async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }) { + return remoteWindows.serialize(payload.id, async () => { + const base = requireHttpUrl(payload.baseUrl, "baseUrl") + const target = requireHttpUrl(payload.entryUrl ?? payload.baseUrl, "entryUrl") + const title = `${payload.name} - ${payload.baseUrl}` + const existing = remoteWindows.reuse(payload.id, payload.proxySessionId) + if (existing) { + const allowedOrigins = new Set([base.origin, target.origin]) + existing.setTitle(title) + await navigateRemoteWindow(existing, target, allowedOrigins, remoteOrigins, insecureOrigins, payload.skipTlsVerify) + return + } + const remoteSession = session.fromPartition(resolveRemoteSessionPartition(payload.id, payload.proxySessionId)) + const window = new BrowserWindow({ + width: 1400, height: 900, minWidth: 800, minHeight: 600, backgroundColor: "#1a1a1a", icon: getIconPath(), title, + webPreferences: { session: remoteSession, preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, additionalArguments: ["--codenomad-window-context=remote"] }, + }) + const nativeWindowId = window.id + const webContentsId = window.webContents.id + const allowedOrigins = new Set([base.origin, target.origin]) + remoteWindows.register(payload.id, window, payload.proxySessionId) + if (isMac) configureMediaPermissionHandlers(() => BrowserWindow.getAllWindows() + .filter((candidate) => candidate.webContents.session === remoteSession) + .flatMap((candidate) => [...(remoteOrigins.get(candidate.id) ?? [])]), remoteSession) + window.setTitle(title) + window.webContents.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle(title) }) + setupNavigationGuards(window, undefined, getAllowedOrigins, getLoadingUrl) + lifecycle.attachSupportWindow(window) + window.on("closed", () => { remoteOrigins.delete(nativeWindowId); insecureOrigins.delete(webContentsId) }) + try { await navigateRemoteWindow(window, target, allowedOrigins, remoteOrigins, insecureOrigins, payload.skipTlsVerify) } catch (error) { + console.warn("[electron] failed to load remote window; showing loading screen", error) + const loading = loadingTarget() + await (loading.url ? window.loadURL(loading.url) : window.loadFile(loading.file!)) + } + }) + } + async function openPreferences(request: PreferencesRequest): Promise { if (preferencesWindows.reuse(request)) { await clientState.setPreferences(request) @@ -387,6 +439,7 @@ function runPrimary(firstIntent: LaunchIntent) { }, }) const nativeWindowId = window.id + const webContentsId = window.webContents.id if (!isMac) window.setMenuBarVisibility(false) preferencesWindows.register(window, request) preferencesNavigation = new ClientStateNavigationController(window, { @@ -399,7 +452,8 @@ function runPrimary(firstIntent: LaunchIntent) { lifecycle.attachSupportWindow(window) window.webContents.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle("Preferences") }) window.on("closed", () => { - windowOrigins.delete(nativeWindowId) + remoteOrigins.delete(nativeWindowId) + insecureOrigins.delete(webContentsId) preferencesNavigation = null preferencesTransition = undefined if (!lifecycle.isExitAllowed()) { @@ -427,7 +481,7 @@ function runPrimary(firstIntent: LaunchIntent) { const target = createPreferencesUrl(url, request.section) await navigation.navigate(async (current, generation) => { if (!navigation.isCurrent(generation)) return - await navigateTrustedWindow(current, target, new Set([target.origin]), windowOrigins) + await navigateRemoteWindow(current, target, new Set([target.origin]), remoteOrigins, insecureOrigins, false) }).catch(async (error) => { if (!isIgnorableNavigationError(error)) console.warn("[electron] failed to load Preferences; showing loading screen", error) await loadPreferencesLoadingNow(window) @@ -457,7 +511,7 @@ function runPrimary(firstIntent: LaunchIntent) { await navigation.navigate(async (current, generation) => { if (!navigation.isCurrent(generation)) return await (target.url ? current.loadURL(target.url) : current.loadFile(target.file!)) - if (navigation.isCurrent(generation)) windowOrigins.delete(current.id) + if (navigation.isCurrent(generation)) remoteOrigins.delete(current.id) }).catch((error) => { preferencesWindows.cancelNavigation(window) if (!isIgnorableNavigationError(error)) console.error("[electron] failed to load Preferences loading screen", error) @@ -519,24 +573,41 @@ async function isRemoteControlEnabled(baseUrl: string | null, cli: CliProcessMan const target = new URL("/api/remote-control/status", baseUrl) const transport = target.protocol === "https:" ? https : http return new Promise((resolve) => { + let settled = false + const finish = (enabled: boolean) => { + if (settled) return + settled = true + resolve(enabled) + } const request = transport.request(target, { method: "GET", headers: { Cookie: `${cookie.name}=${cookie.value}` }, timeout: 2_000, }, (response) => { const chunks: Buffer[] = [] - response.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + let bytes = 0 + response.on("data", (chunk) => { + const value = Buffer.from(chunk) + bytes += value.byteLength + if (bytes > 64 * 1024) { + response.destroy() + finish(false) + return + } + chunks.push(value) + }) response.on("end", () => { try { const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { enabled?: unknown } - resolve(response.statusCode === 200 && payload.enabled === true) + finish(response.statusCode === 200 && payload.enabled === true) } catch { - resolve(false) + finish(false) } }) + response.on("error", () => finish(false)) }) request.on("timeout", () => request.destroy()) - request.on("error", () => resolve(false)) + request.on("error", () => finish(false)) request.end() }) } diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts index 0c201d465..ee278c81d 100644 --- a/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts @@ -106,6 +106,63 @@ test("Remote Control keeps the backend alive after the last local window closes" assert.deepEqual(calls, ["prevent", "renderer:local", "native:local", "remove:local", "close:local"]) }) +test("a window opened during the Remote Control check cancels stale whole-app shutdown", async () => { + const calls: string[] = [] + const first = windowRecord("one", calls) + const second = windowRecord("two", calls) + const local = [first] + let resolveKeepAlive!: (value: boolean) => void + const keepAlive = new Promise((resolve) => { resolveKeepAlive = resolve }) + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => calls.push("quit"), exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => local, + getAllWindows: () => local.map((record) => record.window), + removeWindowState: async (id) => { calls.push(`remove:${id}`); return true }, + getAllowedRendererOrigins: () => ["http://localhost"], + isTrustedRendererOrigin: () => true, + shouldKeepBackendAlive: () => keepAlive, + }) + lifecycle.attach(first) + + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + local.push(second) + resolveKeepAlive(false) + await tick() + await tick() + + assert.deepEqual(calls, ["prevent", "renderer:one", "native:one", "remove:one", "close:one"]) +}) + +test("simultaneous local closes retain one record for whole-app shutdown", async () => { + const calls: string[] = [] + const first = windowRecord("one", calls) + const second = windowRecord("two", calls) + const local = [first, second] + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => calls.push("quit"), exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => local, + getAllWindows: () => local.map((record) => record.window), + removeWindowState: async (id) => { calls.push(`remove:${id}`); return true }, + getAllowedRendererOrigins: () => ["http://localhost"], + isTrustedRendererOrigin: () => true, + shouldKeepBackendAlive: async () => false, + }) + lifecycle.attach(first) + lifecycle.attach(second) + + first.events.get("close")?.({ preventDefault: () => calls.push("prevent:one") }) + second.events.get("close")?.({ preventDefault: () => calls.push("prevent:two") }) + await tick() + await tick() + + assert(calls.includes("quit")) + assert.equal(calls.includes("remove:two"), false) +}) + test("persisted local close waits for confirmed removal and remains retryable", async () => { const calls: string[] = [] const first = windowRecord("one", calls) diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.ts index 20157bbe3..fe8617ab3 100644 --- a/packages/electron-app/electron/main/multiwindow-lifecycle.ts +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.ts @@ -39,39 +39,43 @@ export class MultiwindowLifecycle { private relaunchRequested = false private keepAliveWithoutWindows = false private readonly sessionEndWindows = new WeakSet() + private readonly pendingWindowCloses = new Set() constructor(private readonly dependencies: Dependencies) {} attach(record: LifecycleWindow): void { let approved = false let closing = false + record.window.on("closed", () => this.pendingWindowCloses.delete(record.window)) record.window.on("close", (event) => { if (approved || this.exitAllowed) return event.preventDefault() if (closing || this.shutdown) return - const otherLocal = this.dependencies.getLocalWindows().some((candidate) => candidate.id !== record.id && !candidate.window.isDestroyed()) - const otherWindow = this.dependencies.getAllWindows().some((candidate) => candidate !== record.window - && !candidate.isDestroyed() && !this.dependencies.isSupportWindow?.(candidate)) closing = true + this.pendingWindowCloses.add(record.window) void (async () => { - if (!otherLocal && !otherWindow) { + if (!this.hasOtherApplicationWindow(record.window, record.id)) { const keepAlive = await this.dependencies.shouldKeepBackendAlive?.().catch(() => false) ?? false - if (!keepAlive) { + const stillFinal = !this.hasOtherApplicationWindow(record.window, record.id) + if (!keepAlive && stillFinal) { closing = false + this.pendingWindowCloses.delete(record.window) this.dependencies.app.quit() return } - this.keepAliveWithoutWindows = true + if (keepAlive && stillFinal) this.keepAliveWithoutWindows = true } await this.flushWindow(record) if (record.persisted !== false && !await this.dependencies.removeWindowState(record.id)) { closing = false + this.pendingWindowCloses.delete(record.window) return } approved = true record.window.close() })().catch((error) => { closing = false + this.pendingWindowCloses.delete(record.window) console.warn("[client-state] local window close failed", error) }) }) @@ -88,18 +92,16 @@ export class MultiwindowLifecycle { event.preventDefault() if (closing || this.shutdown) return closing = true - void (this.dependencies.shouldKeepBackendAlive?.() ?? Promise.resolve(false)).then((keepAlive) => { - if (!keepAlive) { + void (this.dependencies.shouldKeepBackendAlive?.().catch(() => false) ?? Promise.resolve(false)).then((keepAlive) => { + const stillFinal = !this.hasOtherApplicationWindow(window) + if (!keepAlive && stillFinal) { closing = false this.dependencies.app.quit() return } - this.keepAliveWithoutWindows = true + if (keepAlive && stillFinal) this.keepAliveWithoutWindows = true approved = true window.close() - }, () => { - closing = false - this.dependencies.app.quit() }) }) this.attachSessionEnd(window) @@ -125,6 +127,7 @@ export class MultiwindowLifecycle { for (const window of visibleWindows) window.hide() void this.startShutdown().then(() => this.exit(), (error) => { this.relaunchRequested = false + this.pendingWindowCloses.clear() for (const window of visibleWindows) if (!window.isDestroyed()) window.show() console.warn("[client-state] shutdown remains pending", error) }) @@ -140,6 +143,14 @@ export class MultiwindowLifecycle { this.dependencies.app.quit() } + private hasOtherApplicationWindow(window: BrowserWindow, localId?: string): boolean { + return (localId !== undefined && this.dependencies.getLocalWindows().some((candidate) => candidate.id !== localId + && !candidate.window.isDestroyed() && !this.pendingWindowCloses.has(candidate.window))) + || this.dependencies.getAllWindows().some((candidate) => candidate !== window + && !candidate.isDestroyed() && !this.pendingWindowCloses.has(candidate) + && !this.dependencies.isSupportWindow?.(candidate)) + } + private startShutdown(preparedFlush?: Promise): Promise { if (this.shutdown) return this.shutdown const cleanup = async () => { diff --git a/packages/electron-app/electron/main/navigation-security.test.ts b/packages/electron-app/electron/main/navigation-security.test.ts index cc0120c91..d06964ab4 100644 --- a/packages/electron-app/electron/main/navigation-security.test.ts +++ b/packages/electron-app/electron/main/navigation-security.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import test from "node:test" import { decideNavigation, requireHttpUrl } from "./navigation-security" -test("trusted renderer URLs require HTTP or HTTPS", () => { +test("remote window URLs require HTTP or HTTPS", () => { assert.equal(requireHttpUrl("http://localhost:3000/app", "baseUrl").protocol, "http:") assert.equal(requireHttpUrl("https://example.com/app", "entryUrl").protocol, "https:") for (const url of ["file:///tmp/index.html", "data:text/html,hello", "javascript:alert(1)"]) { diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index c2f9dc6e3..168e3fb45 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -2,9 +2,11 @@ import { spawn, type ChildProcess } from "child_process" import { app } from "electron" import { createRequire } from "module" import { EventEmitter } from "events" -import { existsSync } from "fs" +import { existsSync, readFileSync } from "fs" +import os from "os" import path from "path" import { fileURLToPath } from "url" +import { parse as parseYaml } from "yaml" import { ensureManagedNodeBinary } from "./managed-node" import { getProcessStartIdentityAsync } from "./client-state-process-identity" import { @@ -29,6 +31,7 @@ const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete" const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete" const SESSION_COOKIE_NAME_PREFIX = "codenomad_session" type CliState = "starting" | "ready" | "error" | "stopped" +type ListeningMode = "local" | "all" export interface CliStatus { state: CliState @@ -55,6 +58,75 @@ interface CliEntryResolution { nodeArgs?: string[] } +const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" + +function isYamlPath(filePath: string): boolean { + const lower = filePath.toLowerCase() + return lower.endsWith(".yaml") || lower.endsWith(".yml") +} + +function isJsonPath(filePath: string): boolean { + return filePath.toLowerCase().endsWith(".json") +} + +function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } { + const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH + const resolved = resolveConfigPath(target) + + if (isYamlPath(resolved)) { + const baseDir = path.dirname(resolved) + return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") } + } + + if (isJsonPath(resolved)) { + const baseDir = path.dirname(resolved) + return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved } + } + + // Treat as directory. + return { + configYamlPath: path.join(resolved, "config.yaml"), + legacyJsonPath: path.join(resolved, "config.json"), + } +} + +function resolveConfigPath(configPath?: string): string { + const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH + if (target.startsWith("~/")) { + return path.join(os.homedir(), target.slice(2)) + } + return path.resolve(target) +} + +function resolveHostForMode(mode: ListeningMode): string { + return mode === "local" ? "127.0.0.1" : "0.0.0.0" +} + +function readListeningModeFromConfig(): ListeningMode { + try { + const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG) + + let parsed: any = null + if (existsSync(configYamlPath)) { + const content = readFileSync(configYamlPath, "utf-8") + parsed = parseYaml(content) + } else if (existsSync(legacyJsonPath)) { + const content = readFileSync(legacyJsonPath, "utf-8") + parsed = JSON.parse(content) + } else { + return "local" + } + + const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode + if (mode === "local" || mode === "all") { + return mode + } + } catch (error) { + console.warn("[cli] failed to read listening mode from config", error) + } + return "local" +} + export declare interface CliProcessManager { on(event: "status", listener: (status: CliStatus) => void): this on(event: "ready", listener: (status: CliStatus) => void): this @@ -125,12 +197,14 @@ export class CliProcessManager extends EventEmitter { this.childStartIdentity = undefined this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) - const args = this.buildCliArgs(options) + const listeningMode = this.resolveListeningMode() + const host = resolveHostForMode(listeningMode) + const args = this.buildCliArgs(options, host) const cliEntry = await this.awaitStartupStep(this.resolveCliEntry(options)) if (this.lifecycle.stopped) throw new Error("CLI startup interrupted by shutdown") console.info( - `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=127.0.0.1)`, + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, ) const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } @@ -322,6 +396,10 @@ export class CliProcessManager extends EventEmitter { }) } + private resolveListeningMode(): ListeningMode { + return readListeningModeFromConfig() + } + private handleTimeout() { const timedOutChild = this.child if (timedOutChild) { @@ -436,13 +514,14 @@ export class CliProcessManager extends EventEmitter { this.emit("status", this.status) } - private buildCliArgs(options: StartOptions): string[] { - const args = ["serve", "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"] + private buildCliArgs(options: StartOptions, host: string): string[] { + const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"] if (options.dev) { // Dev: run plain HTTP + Vite dev server proxy. args.push("--https", "false", "--http", "true") - // Avoid collisions with an already-running server by forcing an ephemeral port in dev. + // Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks) + // by forcing an ephemeral port in dev. args.push("--http-port", "0") } else { // Prod desktop: always keep loopback HTTP enabled. diff --git a/packages/electron-app/electron/main/remote-window-registry.test.ts b/packages/electron-app/electron/main/remote-window-registry.test.ts new file mode 100644 index 000000000..dc1488c6d --- /dev/null +++ b/packages/electron-app/electron/main/remote-window-registry.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { BrowserWindow } from "electron" +import { navigateRemoteWindow, RemoteWindowRegistry } from "./remote-window-registry" + +function window() { + const events = new Map void>() + const calls: string[] = [] + return { + calls, + events, + value: { + isDestroyed: () => false, + isMinimized: () => false, + restore: () => calls.push("restore"), + show: () => calls.push("show"), + focus: () => calls.push("focus"), + close: () => { calls.push("close"); events.get("close")?.() }, + destroy: () => calls.push("destroy"), + on: (name: string, callback: () => void) => events.set(name, callback), + } as unknown as BrowserWindow, + } +} + +test("remote profiles reuse one window and preserve direct profile sessions", () => { + const cleaned: string[] = [] + const registry = new RemoteWindowRegistry((id) => cleaned.push(id)) + const direct = window() + registry.register("profile", direct.value) + assert.equal(registry.reuse("profile"), direct.value) + assert.deepEqual(direct.calls, ["show", "focus"]) + direct.events.get("closed")?.() + assert.deepEqual(cleaned, []) +}) + +test("proxy replacement destroys the old window without triggering close interception", () => { + const cleaned: string[] = [] + const registry = new RemoteWindowRegistry((id) => cleaned.push(id)) + const first = window() + first.events.set("close", () => first.calls.push("quit")) + registry.register("profile", first.value, "proxy-one") + assert.equal(registry.reuse("profile", "proxy-two"), undefined) + assert.deepEqual(first.calls, ["destroy"]) + assert.deepEqual(cleaned, ["proxy-one"]) + const second = window() + registry.register("profile", second.value, "proxy-two") + first.events.get("closed")?.() + second.events.get("closed")?.() + assert.deepEqual(cleaned, ["proxy-one", "proxy-two"]) +}) + +test("reused remote navigation trusts old and next redirect origins until success", async () => { + const remote = window() + const trusted = new Map([[1, new Set(["https://old.example"])]]) + const insecure = new Map([[2, new Set(["https://old.example"])]]) + Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) + remote.value.loadURL = async () => { + assert.deepEqual([...trusted.get(1)!], ["https://old.example", "https://new.example", "https://redirect.example"]) + } + + const next = new Set(["https://new.example", "https://redirect.example"]) + await navigateRemoteWindow(remote.value, new URL("https://new.example/app"), next, trusted, insecure, false) + assert.deepEqual([...trusted.get(1)!], [...next]) + assert.equal(insecure.has(2), false) +}) + +test("failed reused remote navigation restores trusted and insecure origins", async () => { + const remote = window() + const trusted = new Map([[1, new Set(["https://old.example"])]]) + const insecure = new Map([[2, new Set(["https://old.example"])]]) + Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) + remote.value.loadURL = async () => { + assert.deepEqual([...trusted.get(1)!], ["https://old.example", "https://new.example"]) + assert.deepEqual([...insecure.get(2)!], ["https://old.example", "https://new.example"]) + throw new Error("failed") + } + + const next = new Set(["https://new.example"]) + await assert.rejects(navigateRemoteWindow(remote.value, new URL("https://new.example/app"), next, trusted, insecure, true), /failed/) + assert.deepEqual([...trusted.get(1)!], ["https://old.example"]) + assert.deepEqual([...insecure.get(2)!], ["https://old.example"]) +}) + +test("stale remote navigation failure cannot replace newer committed authority", async () => { + const remote = window() + const trusted = new Map([[1, new Set(["https://old.example"])]]) + const insecure = new Map([[2, new Set(["https://old.example"])]]) + const loads: Array<{ resolve: () => void; reject: (error: Error) => void }> = [] + Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) + remote.value.loadURL = () => new Promise((resolve, reject) => loads.push({ resolve, reject })) + + const stale = navigateRemoteWindow(remote.value, new URL("https://stale.example"), new Set(["https://stale.example"]), trusted, insecure, true) + const current = navigateRemoteWindow(remote.value, new URL("https://current.example"), new Set(["https://current.example"]), trusted, insecure, false) + loads[1]!.resolve() + await current + loads[0]!.reject(new Error("stale failed")) + await stale + + assert.deepEqual([...trusted.get(1)!], ["https://current.example"]) + assert.equal(insecure.has(2), false) +}) + +test("stale remote navigation success cannot replace authority restored by a newer failure", async () => { + const remote = window() + const trusted = new Map([[1, new Set(["https://old.example"])]]) + const insecure = new Map([[2, new Set(["https://old.example"])]]) + const loads: Array<{ resolve: () => void; reject: (error: Error) => void }> = [] + Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) + remote.value.loadURL = () => new Promise((resolve, reject) => loads.push({ resolve, reject })) + + const stale = navigateRemoteWindow(remote.value, new URL("https://stale.example"), new Set(["https://stale.example"]), trusted, insecure, false) + const current = navigateRemoteWindow(remote.value, new URL("https://current.example"), new Set(["https://current.example"]), trusted, insecure, true) + loads[1]!.reject(new Error("current failed")) + await assert.rejects(current, /current failed/) + loads[0]!.resolve() + await stale + + assert.deepEqual([...trusted.get(1)!], ["https://old.example"]) + assert.deepEqual([...insecure.get(2)!], ["https://old.example"]) +}) + +test("overlapping remote opens wait for the prior loadURL fallback for the same profile", async () => { + const registry = new RemoteWindowRegistry(() => {}) + const calls: string[] = [] + let releaseFallback!: () => void + const fallback = new Promise((resolve) => { releaseFallback = resolve }) + const first = registry.serialize("profile", async () => { + calls.push("first-loadURL") + try { throw new Error("load failed") } catch { calls.push("first-fallback"); await fallback } + calls.push("first-done") + }) + const second = registry.serialize("profile", async () => { calls.push("second-loadURL") }) + + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, ["first-loadURL", "first-fallback"]) + releaseFallback() + await Promise.all([first, second]) + assert.deepEqual(calls, ["first-loadURL", "first-fallback", "first-done", "second-loadURL"]) +}) diff --git a/packages/electron-app/electron/main/remote-window-registry.ts b/packages/electron-app/electron/main/remote-window-registry.ts new file mode 100644 index 000000000..1b129d292 --- /dev/null +++ b/packages/electron-app/electron/main/remote-window-registry.ts @@ -0,0 +1,99 @@ +import type { BrowserWindow } from "electron" + +interface RemoteWindowRecord { + window: BrowserWindow + proxySessionId?: string +} + +export class RemoteWindowRegistry { + private readonly records = new Map() + private readonly operations = new Map>() + + constructor(private readonly cleanupProxySession: (sessionId: string) => void) {} + + serialize(profileId: string, operation: () => Promise): Promise { + const previous = this.operations.get(profileId) ?? Promise.resolve() + const result = previous.catch(() => {}).then(operation) + const tail = result.then(() => {}, () => {}) + this.operations.set(profileId, tail) + void tail.then(() => { + if (this.operations.get(profileId) === tail) this.operations.delete(profileId) + }) + return result + } + + reuse(profileId: string, proxySessionId?: string): BrowserWindow | undefined { + const record = this.records.get(profileId) + if (!record || record.window.isDestroyed()) return undefined + if (record.proxySessionId !== proxySessionId) { + this.records.delete(profileId) + record.window.destroy() + if (record.proxySessionId) this.cleanupProxySession(record.proxySessionId) + return undefined + } + if (record.window.isMinimized()) record.window.restore() + record.window.show() + record.window.focus() + return record.window + } + + register(profileId: string, window: BrowserWindow, proxySessionId?: string): void { + const record = { window, proxySessionId } + this.records.set(profileId, record) + window.on("closed", () => { + if (this.records.get(profileId) !== record) return + this.records.delete(profileId) + if (proxySessionId) this.cleanupProxySession(proxySessionId) + }) + } +} + +interface RemoteNavigationAuthority { + generation: number + trustedOrigins: Set + insecureOrigins: Set +} + +const navigationAuthorities = new WeakMap() + +export async function navigateRemoteWindow( + window: BrowserWindow, + target: URL, + nextOrigins: ReadonlySet, + trustedOrigins: Map>, + insecureOrigins: Map>, + skipTlsVerify: boolean, +): Promise { + let authority = navigationAuthorities.get(window) + if (!authority) { + authority = { + generation: 0, + trustedOrigins: new Set(trustedOrigins.get(window.id)), + insecureOrigins: new Set(insecureOrigins.get(window.webContents.id)), + } + navigationAuthorities.set(window, authority) + } + const generation = ++authority.generation + const committedOrigins = new Set(nextOrigins) + trustedOrigins.set(window.id, new Set([...authority.trustedOrigins, ...committedOrigins])) + const provisionalInsecure = new Set(authority.insecureOrigins) + if (skipTlsVerify) for (const origin of committedOrigins) provisionalInsecure.add(origin) + if (provisionalInsecure.size) insecureOrigins.set(window.webContents.id, provisionalInsecure) + else insecureOrigins.delete(window.webContents.id) + + try { await window.loadURL(target.toString()) } catch (error) { + if (authority.generation !== generation) return + if (authority.trustedOrigins.size) trustedOrigins.set(window.id, new Set(authority.trustedOrigins)) + else trustedOrigins.delete(window.id) + if (authority.insecureOrigins.size) insecureOrigins.set(window.webContents.id, new Set(authority.insecureOrigins)) + else insecureOrigins.delete(window.webContents.id) + throw error + } + + if (authority.generation !== generation) return + authority.trustedOrigins = committedOrigins + authority.insecureOrigins = skipTlsVerify ? new Set(committedOrigins) : new Set() + trustedOrigins.set(window.id, committedOrigins) + if (authority.insecureOrigins.size) insecureOrigins.set(window.webContents.id, new Set(authority.insecureOrigins)) + else insecureOrigins.delete(window.webContents.id) +} diff --git a/packages/electron-app/electron/main/startup.test.ts b/packages/electron-app/electron/main/startup.test.ts index 529f89307..1241728bb 100644 --- a/packages/electron-app/electron/main/startup.test.ts +++ b/packages/electron-app/electron/main/startup.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" -import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, parseLaunchIntent, prepareSecondLaunchIntent, resolveStorageScope, resolveUpdateChannel, startPrimaryInstance } from "./startup" +import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, prepareSecondLaunchIntent, resolveRemoteSessionPartition, resolveStorageScope, resolveUpdateChannel, startPrimaryInstance } from "./startup" test("update channel honors the environment, forces unpackaged dev, and only infers packaged versions", () => { assert.equal(resolveUpdateChannel("Beta", "1.0.0-dev.2", false), "beta") @@ -29,6 +29,17 @@ test("stable default storage preserves paths while dev and alternate configs are assert.equal(resolveStorageScope({ appVersion: "1.0.0", cliConfig: "other/config.yaml", cwd: base, baseUserDataPath: base, packaged: true }).userDataPath, alternate.userDataPath) }) +test("remote profiles use isolated persistent partitions and TLS exceptions stay with their webContents", () => { + const first = resolveRemoteSessionPartition("profile-a") + assert.match(first, /^persist:codenomad-remote-[0-9a-f]{24}$/) + assert.equal(resolveRemoteSessionPartition("profile-a"), first) + assert.notEqual(resolveRemoteSessionPartition("profile-b"), first) + assert.match(resolveRemoteSessionPartition("profile-a", "proxy-1"), /^codenomad-remote-/) + const allowlists = new Map([[7, new Set(["https://unsafe.example"])], [8, new Set(["https://other.example"])]] as const) + assert.equal(isRemoteCertificateAllowed(7, "https://unsafe.example/path", allowlists), true) + assert.equal(isRemoteCertificateAllowed(8, "https://unsafe.example/path", allowlists), false) +}) + test("new local windows reuse retained records and otherwise fall back to ephemeral identities", async () => { let additions = 0 assert.deepEqual(await allocateLocalWindowIdentity(["retained"], () => false, async () => { additions++; return "new" }), { id: "retained", persisted: true }) diff --git a/packages/electron-app/electron/main/startup.ts b/packages/electron-app/electron/main/startup.ts index 11f2fa631..1860d3d62 100644 --- a/packages/electron-app/electron/main/startup.ts +++ b/packages/electron-app/electron/main/startup.ts @@ -131,6 +131,20 @@ export function resolveStorageScope(options: { } } +export function resolveRemoteSessionPartition(profileId: string, proxySessionId?: string): string { + const identity = proxySessionId ? `${profileId}\0${proxySessionId}` : profileId + const suffix = createHash("sha256").update(identity).digest("hex").slice(0, 24) + return `${proxySessionId ? "" : "persist:"}codenomad-remote-${suffix}` +} + +export function isRemoteCertificateAllowed( + webContentsId: number, + url: string, + insecureOrigins: ReadonlyMap>, +): boolean { + try { return insecureOrigins.get(webContentsId)?.has(new URL(url).origin) ?? false } catch { return false } +} + export async function allocateLocalWindowIdentity( persistedIds: readonly string[], isRegistered: (id: string) => boolean, diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index e93e319b0..c5c41c9ec 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -58,6 +58,7 @@ const localElectronAPI = { requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), + openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), openPreferences: (section, context) => ipcRenderer.invoke("preferences:open", section, context), minimizeWindow: () => ipcRenderer.invoke("preferences:minimize"), toggleMaximizeWindow: () => ipcRenderer.invoke("preferences:toggleMaximize"), @@ -87,6 +88,7 @@ const preferencesElectronAPI = { restartCli: localElectronAPI.restartCli, openDialog: localElectronAPI.openDialog, showNotification: localElectronAPI.showNotification, + openRemoteWindow: localElectronAPI.openRemoteWindow, getPreferencesSection: () => ipcRenderer.invoke("preferences:getSection"), getPreferencesRequest: () => ipcRenderer.invoke("preferences:getSection"), preferencesReady: () => ipcRenderer.invoke("preferences:ready"), diff --git a/packages/electron-app/electron/preload/index.test.ts b/packages/electron-app/electron/preload/index.test.ts index 391174a73..69d3ecf7f 100644 --- a/packages/electron-app/electron/preload/index.test.ts +++ b/packages/electron-app/electron/preload/index.test.ts @@ -57,7 +57,7 @@ test("Preferences preload exposes only section and frame controls", () => { const api = exposed.get("electronAPI") as Record assert.deepEqual(Object.keys(api), [ - "onCliStatus", "onCliError", "getCliStatus", "restartCli", "openDialog", "showNotification", + "onCliStatus", "onCliError", "getCliStatus", "restartCli", "openDialog", "showNotification", "openRemoteWindow", "getPreferencesSection", "getPreferencesRequest", "preferencesReady", "acceptPreferencesRequest", "resolvePreferencesTransition", "onPreferencesSection", "onPreferencesCloseRequested", "onPreferencesTransitionRequested", "minimizeWindow", "toggleMaximizeWindow", "closeWindow", diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 2ff279231..7730e4fec 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -24,7 +24,7 @@ "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/developer-mode.test.ts electron/main/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.test.ts electron/main/native-request.test.ts electron/main/navigation-security.test.ts electron/main/preferences-ipc.test.ts electron/main/preferences-window.test.ts electron/main/process-exit.test.ts electron/main/process-output.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/startup.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts electron/preload/index.test.ts", + "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/developer-mode.test.ts electron/main/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.test.ts electron/main/native-request.test.ts electron/main/navigation-security.test.ts electron/main/preferences-ipc.test.ts electron/main/preferences-window.test.ts electron/main/process-exit.test.ts electron/main/process-output.test.ts electron/main/process-stop.test.ts electron/main/remote-window-registry.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/startup.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts electron/preload/index.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", diff --git a/packages/remote-control-protocol/src/crypto.ts b/packages/remote-control-protocol/src/crypto.ts index 3254c66d4..89341e646 100644 --- a/packages/remote-control-protocol/src/crypto.ts +++ b/packages/remote-control-protocol/src/crypto.ts @@ -62,6 +62,7 @@ export async function createClientHandshake(hostPublicJwk: JsonWebKey): Promise< hello: JSON.stringify(hello), async accept(message: string) { if (accepted) throw new Error("Remote Control handshake was already accepted") + accepted = true const ready = parseReady(message) if (!ready) throw new Error("Invalid Remote Control host handshake") const hostNonce = decodeNonce(ready.nonce) @@ -69,7 +70,6 @@ export async function createClientHandshake(hostPublicJwk: JsonWebKey): Promise< const channel = createChannel(keys.clientToHost, keys.hostToClient) const proof = await channel.decrypt(decodeBase64(ready.proof)) if (!equalBytes(proof, READY_PROOF)) throw new Error("Remote Control host handshake authentication failed") - accepted = true return channel }, } @@ -81,6 +81,7 @@ export async function createHostHandshake(hostPrivateJwk: JsonWebKey): Promise { + const budget = new FrameBudget(2, 10) + const releaseFirst = budget.reserve(6) + const releaseSecond = budget.reserve(4) + assert.ok(releaseFirst) + assert.ok(releaseSecond) + assert.equal(budget.reserve(0), null) + assert.equal(budget.reserve(1), null) + assert.deepEqual(budget.usage(), { frames: 2, bytes: 10 }) + + releaseFirst() + const releaseReplacement = budget.reserve(5) + assert.ok(releaseReplacement) + assert.deepEqual(budget.usage(), { frames: 2, bytes: 9 }) + releaseFirst() + releaseSecond() + releaseReplacement() + assert.deepEqual(budget.usage(), { frames: 0, bytes: 0 }) +}) + +test("rejects invalid budget limits and frame sizes", () => { + assert.throws(() => new FrameBudget(0, 1), RangeError) + assert.throws(() => new FrameBudget(1, Number.POSITIVE_INFINITY), RangeError) + const budget = new FrameBudget(1, 1) + assert.throws(() => budget.reserve(-1), RangeError) + assert.throws(() => budget.reserve(1.5), RangeError) +}) diff --git a/packages/remote-control-protocol/src/frame-budget.ts b/packages/remote-control-protocol/src/frame-budget.ts new file mode 100644 index 000000000..fb11bcd07 --- /dev/null +++ b/packages/remote-control-protocol/src/frame-budget.ts @@ -0,0 +1,33 @@ +export class FrameBudget { + private frames = 0 + private bytes = 0 + + constructor( + private readonly maxFrames: number, + private readonly maxBytes: number, + ) { + if (!Number.isSafeInteger(maxFrames) || maxFrames < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new RangeError("Remote Control frame budget limits must be positive integers") + } + } + + reserve(byteLength: number): (() => void) | null { + if (!Number.isSafeInteger(byteLength) || byteLength < 0) { + throw new RangeError("Remote Control frame size must be a non-negative integer") + } + if (this.frames >= this.maxFrames || byteLength > this.maxBytes - this.bytes) return null + this.frames += 1 + this.bytes += byteLength + let active = true + return () => { + if (!active) return + active = false + this.frames -= 1 + this.bytes -= byteLength + } + } + + usage(): { frames: number; bytes: number } { + return { frames: this.frames, bytes: this.bytes } + } +} diff --git a/packages/remote-control-protocol/src/index.test.ts b/packages/remote-control-protocol/src/index.test.ts index 235066173..449a20b45 100644 --- a/packages/remote-control-protocol/src/index.test.ts +++ b/packages/remote-control-protocol/src/index.test.ts @@ -37,6 +37,49 @@ test("encrypted frames fail closed after tampering", async () => { await assert.rejects(() => accepted.channel.decrypt(frame), /authentication failed/) }) +test("encrypted channels reject a skipped counter before exposing later plaintext", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const client = await createClientHandshake(await crypto.subtle.exportKey("jwk", pair.publicKey)) + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", pair.privateKey)) + const accepted = await host.accept(client.hello) + const clientChannel = await client.accept(accepted.ready) + const first = await clientChannel.encrypt(new TextEncoder().encode("first")) + const second = await clientChannel.encrypt(new TextEncoder().encode("second")) + + await assert.rejects(() => accepted.channel.decrypt(second), /out of order/) + assert.equal(new TextDecoder().decode(await accepted.channel.decrypt(first)), "first") +}) + +test("encrypted channels reject concurrent replay before duplicate plaintext is exposed", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const client = await createClientHandshake(await crypto.subtle.exportKey("jwk", pair.publicKey)) + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", pair.privateKey)) + const accepted = await host.accept(client.hello) + const clientChannel = await client.accept(accepted.ready) + const frame = await clientChannel.encrypt(new TextEncoder().encode("once")) + const results = await Promise.allSettled([ + accepted.channel.decrypt(frame), + accepted.channel.decrypt(frame), + ]) + + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1) + assert.equal(results.filter((result) => result.status === "rejected").length, 1) +}) + +test("handshake objects admit only one concurrent acceptance", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const publicKey = await crypto.subtle.exportKey("jwk", pair.publicKey) + const privateKey = await crypto.subtle.exportKey("jwk", pair.privateKey) + const client = await createClientHandshake(publicKey) + const host = await createHostHandshake(privateKey) + const hostAcceptance = host.accept(client.hello) + await assert.rejects(() => host.accept(client.hello), /already accepted/) + const accepted = await hostAcceptance + const clientAcceptance = client.accept(accepted.ready) + await assert.rejects(() => client.accept(accepted.ready), /already accepted/) + await clientAcceptance +}) + test("host challenge prevents encrypted requests from being replayed into a new tunnel", async () => { const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair const publicKey = await crypto.subtle.exportKey("jwk", pair.publicKey) diff --git a/packages/remote-control-protocol/src/index.ts b/packages/remote-control-protocol/src/index.ts index 4bca6070f..dd3b1584e 100644 --- a/packages/remote-control-protocol/src/index.ts +++ b/packages/remote-control-protocol/src/index.ts @@ -1,2 +1,3 @@ export * from "./crypto" +export * from "./frame-budget" export * from "./messages" diff --git a/packages/remote-control-protocol/src/messages.ts b/packages/remote-control-protocol/src/messages.ts index 2f0542cd6..9aec122ad 100644 --- a/packages/remote-control-protocol/src/messages.ts +++ b/packages/remote-control-protocol/src/messages.ts @@ -3,6 +3,7 @@ export const REMOTE_CONTROL_HEARTBEAT_REQUEST = "codenomad.remote-control.ping.v export const REMOTE_CONTROL_HEARTBEAT_RESPONSE = "codenomad.remote-control.pong.v2" export const REMOTE_CONTROL_MAX_HANDSHAKE_BYTES = 4 * 1024 export const REMOTE_CONTROL_MAX_HTTP_BODY_BYTES = 12 * 1024 * 1024 +export const REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES = 12 * 1024 * 1024 export const REMOTE_CONTROL_MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024 export type HeaderEntries = Array<[string, string]> diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index ee629267c..ce6df59bb 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -398,6 +398,42 @@ export interface YoloStateResponse { enabled: boolean } +export interface RemoteServerProfile { + id: string + name: string + baseUrl: string + skipTlsVerify: boolean + createdAt: string + updatedAt: string + lastConnectedAt?: string +} + +export interface RemoteServerProbeRequest { + baseUrl: string + skipTlsVerify?: boolean +} + +export interface RemoteServerProbeResponse { + ok: boolean + reachable: boolean + normalizedUrl: string + skipTlsVerify: boolean + requiresAuth: boolean + authenticated: boolean + error?: string + errorCode?: string +} + +export interface RemoteProxySessionCreateRequest { + baseUrl: string + skipTlsVerify?: boolean +} + +export interface RemoteProxySessionCreateResponse { + sessionId: string + windowUrl: string +} + export type { RemoteControlDevice, RemoteControlPairing, @@ -437,6 +473,14 @@ export type WorkspaceEventPayload = | { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean } | { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string } +export interface NetworkAddress { + ip: string + family: "ipv4" | "ipv6" + scope: "external" | "internal" | "loopback" + /** Remote URL using the server's remote protocol/port for this IP. */ + remoteUrl: string +} + export interface LatestReleaseInfo { version: string tag: string @@ -462,16 +506,24 @@ export interface SupportMeta { export interface ServerMeta { /** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */ localUrl: string + /** URL direct remote clients should use (prefers HTTPS when enabled). */ + remoteUrl?: string /** SSE endpoint advertised to clients (`/api/events` by default). */ eventsUrl: string - /** Loopback host the server is bound to. */ + /** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */ host: string + /** Listening mode derived from host binding. */ + listeningMode: "local" | "all" /** Actual local port in use after binding. */ localPort: number + /** Actual direct remote port in use after binding (when remoteUrl is set). */ + remotePort?: number /** Display label for the host (e.g., hostname or friendly name). */ hostLabel: string /** Absolute path of the filesystem root exposed to clients. */ workspaceRoot: string + /** Reachable direct-access addresses for this server, external first. */ + addresses: NetworkAddress[] serverVersion?: string ui?: UiMeta support?: SupportMeta diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index 76c802b74..c2ca2a0e0 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -26,6 +26,7 @@ const PreferencesSchema = z showUsageMetrics: z.boolean().default(true), usageMetricsExpansion: z.enum(["expanded", "collapsed"]).default("collapsed"), autoCleanupBlankSessions: z.boolean().default(true), + listeningMode: z.enum(["local", "all"]).default("local"), logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"), // OS notifications diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 55f44b079..78d1534d7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -21,6 +21,8 @@ import { launchInBrowser } from "./launcher" import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { resolveHttpsOptions } from "./server/tls" +import { RemoteProxySessionManager } from "./server/remote-proxy" +import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { resolveAutomationBridgeUrl, resolvePluginBaseUrl } from "./server/listener-base-url" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" @@ -43,8 +45,10 @@ const packageJson = require("../package.json") as { version: string } const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public") +const DEFAULT_REMOTE_CONTROL_RELAY_URL = "https://remote.codenomad.neuralnomads.ai" interface CliOptions { + host: string https: boolean http: boolean httpsPort: number @@ -76,7 +80,6 @@ const DEFAULT_HOST = "127.0.0.1" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_HTTPS_PORT = 9898 const DEFAULT_HTTP_PORT = 9899 -const DEFAULT_REMOTE_CONTROL_RELAY_URL = "https://remote.codenomad.neuralnomads.ai" export const STDIN_SHUTDOWN_COMMAND = "codenomad:shutdown" interface ShutdownSignalSource { @@ -129,6 +132,7 @@ function parseCliOptions(argv: string[]): CliOptions { .name("codenomad") .description("CodeNomad CLI server") .version(packageJson.version, "-v, --version", "Show the CLI version") + .addOption(new Option("--host ", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST)) .addOption(new Option("--https ", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true")) .addOption(new Option("--http ", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false")) .addOption(new Option("--https-port ", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort)) @@ -172,7 +176,7 @@ function parseCliOptions(argv: string[]): CliOptions { .addOption( new Option( "--dangerously-skip-auth", - "Disable CodeNomad's internal auth. Use only for isolated local development.", + "Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).", ) .env("CODENOMAD_SKIP_AUTH") .default(false), @@ -181,6 +185,7 @@ function parseCliOptions(argv: string[]): CliOptions { program.parse(argv, { from: "user" }) const parsed = program.opts<{ + host: string https?: string http?: string httpsPort: number @@ -217,6 +222,8 @@ function parseCliOptions(argv: string[]): CliOptions { const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() + const normalizedHost = resolveHost(parsed.host) + const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase() const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes" @@ -228,6 +235,7 @@ function parseCliOptions(argv: string[]): CliOptions { } return { + host: normalizedHost, https: httpsEnabled, http: httpEnabled, httpsPort: parsed.httpsPort, @@ -264,6 +272,21 @@ function parsePort(input: string): number { return value } +function resolveHost(input: string | undefined): string { + const trimmed = input?.trim() + if (!trimmed) return DEFAULT_HOST + + if (trimmed === "0.0.0.0") { + return "0.0.0.0" + } + + if (trimmed === "localhost") { + return DEFAULT_HOST + } + + return trimmed +} + export function programHasArg(argv: string[], flag: string): boolean { return argv.some((argument) => argument === flag || argument.startsWith(`${flag}=`)) } @@ -296,6 +319,8 @@ async function main() { const eventBus = new EventBus(eventLogger) + const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") + const configLocation = resolveConfigLocation(options.configPath) const configDir = configLocation.baseDir @@ -305,11 +330,15 @@ async function main() { const serverMeta: ServerMeta = { localUrl: "http://localhost:0", + remoteUrl: undefined, eventsUrl: `/api/events`, - host: DEFAULT_HOST, + host: options.host, + listeningMode: isLoopbackHost(options.host) ? "local" : "all", localPort: 0, - hostLabel: DEFAULT_HOST, + remotePort: undefined, + hostLabel: options.host, workspaceRoot: options.rootDir, + addresses: [], } const authManager = new AuthManager( @@ -334,7 +363,7 @@ async function main() { const tlsResolution = resolveHttpsOptions({ enabled: options.https, configDir, - host: DEFAULT_HOST, + host: options.host, tlsKeyPath: options.tlsKeyPath, tlsCertPath: options.tlsCertPath, tlsCaPath: options.tlsCaPath, @@ -432,7 +461,14 @@ async function main() { }) : null + const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host) + const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" })) + const remoteProxySessionManager = new RemoteProxySessionManager({ + authManager, + logger: logger.child({ component: "remote-proxy" }), + httpsOptions: tlsResolution?.httpsOptions, + }) const remoteControlSession = authManager.createSession(options.authUsername) const remoteControlManager = new RemoteControlManager({ identity: loadOrCreateRemoteControlIdentity(configDir), @@ -447,10 +483,12 @@ async function main() { const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0 const httpBindPort = httpPortExplicit ? options.httpPort : 0 - // Remote Control uses an outbound relay connection. CodeNomad itself never - // accepts connections from a LAN or public interface. - const httpsBindHost = DEFAULT_HOST - const httpBindHost = DEFAULT_HOST + // Listener binding rules: + // - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP). + // - Remote access disabled: both listen on loopback. + // - HTTP-only mode: respect --host (used for dev/testing). + const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1" + const httpBindHost = nativeParent.available ? "127.0.0.1" : options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1" const servers: Array> = [] @@ -471,6 +509,7 @@ async function main() { previewManager, authManager, clientConnectionManager, + remoteProxySessionManager, remoteControlManager, yoloManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, @@ -499,6 +538,7 @@ async function main() { previewManager, authManager, clientConnectionManager, + remoteProxySessionManager, remoteControlManager, yoloManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, @@ -523,14 +563,43 @@ async function main() { throw new Error("No listeners started") } + const remoteStart = httpsStart ?? httpStart + const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http" + + let remoteUrl: string | undefined + let remoteAddresses = [] as ReturnType + if (remoteStart) { + const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host) + let remoteHost = options.host + if (wantsAll) { + if (options.host === "0.0.0.0") { + const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) + remoteAddresses = resolved.userVisible + remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}` + } + } else { + remoteHost = "localhost" + } + if (!remoteUrl) { + remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}` + } + } + + // Prefer an explicit IPv4 loopback address only when one of the bound listeners + // accepts loopback. Concrete LAN bindings do not, so plugins need the reachable + // bound/listener URL instead of an unreachable 127.0.0.1 URL. const localUrl = resolvePluginBaseUrl({ httpStart: visibleHttpStart ? { protocol: "http", bindHost: httpBindHost, port: visibleHttpStart.port } : null, httpsStart: httpsStart ? { protocol: "https", bindHost: httpsBindHost, port: httpsStart.port } : null, + remoteUrl, }) serverMeta.localUrl = localUrl serverMeta.localPort = localStart.port - serverMeta.host = DEFAULT_HOST + serverMeta.remoteUrl = remoteUrl + serverMeta.remotePort = remoteStart?.port + serverMeta.host = options.host + serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local" let removeAutomationBridge: (() => Promise) | undefined if (nativeParent.available && process.env.CODENOMAD_DEVELOPER_MODE === "1") { @@ -546,7 +615,28 @@ async function main() { } } + if (serverMeta.remotePort && remoteUrl) { + serverMeta.addresses = remoteAddresses.length + ? remoteAddresses + : resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort }) + } else { + serverMeta.addresses = [] + } + console.log(`Local Connection URL : ${serverMeta.localUrl}`) + if (serverMeta.remoteUrl) { + console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`) + const additionalRemoteUrls = serverMeta.addresses + .map((addr) => addr.remoteUrl) + .filter((url) => url !== serverMeta.remoteUrl) + + if (additionalRemoteUrls.length > 0) { + console.log("Other Accessible URLs:") + for (const url of additionalRemoteUrls) { + console.log(` - ${url}`) + } + } + } if (options.launch) { await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" })) @@ -565,6 +655,7 @@ async function main() { stopInstanceEventBridge: () => instanceEventBridge.shutdown(), stopSidecars: () => sidecarManager.shutdown(), stopClientConnections: () => clientConnectionManager.shutdown(), + stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(), stopRemoteControl: () => remoteControlManager.shutdown(), stopWorkspaces: () => workspaceManager.shutdown(), stopHttpServers: async () => { diff --git a/packages/server/src/remote-control/connector-protocol.ts b/packages/server/src/remote-control/connector-protocol.ts index 4460e7829..5af8fa93b 100644 --- a/packages/server/src/remote-control/connector-protocol.ts +++ b/packages/server/src/remote-control/connector-protocol.ts @@ -3,19 +3,39 @@ import type { ClientToHostMessage, HeaderEntries, RelayToHostMessage } from "@co const RESPONSE_HEADER_BLOCKLIST = new Set(["connection", "content-encoding", "content-length", "set-cookie", "transfer-encoding", "upgrade"]) const REQUEST_HEADER_BLOCKLIST = new Set([ "authorization", + "cf-connecting-ip", + "client-ip", "connection", + "content-length", "cookie", "forwarded", "host", + "keep-alive", "origin", "proxy-authorization", "referer", + "te", + "trailer", "transfer-encoding", + "true-client-ip", "upgrade", + "via", + "x-real-ip", ]) export const ALLOWED_REMOTE_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]) const ALLOWED_REMOTE_PATH_PREFIXES = ["/api/", "/workspaces/"] +const MESSAGE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ +const PROTOCOL_PATTERN = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/ +const MAX_PATH_CHARS = 8 * 1024 +const MAX_HEADER_ENTRIES = 256 +const MAX_HEADER_NAME_CHARS = 256 +const MAX_HEADER_VALUE_BYTES = 16 * 1024 +const MAX_HEADER_BYTES = 64 * 1024 +const MAX_PROTOCOLS = 16 +const MAX_PROTOCOL_CHARS = 128 +const MAX_CLOSE_REASON_CHARS = 120 export function relaySocketUrl(relayUrl: string, hostId: string): URL { const url = new URL(`/api/hosts/${hostId}/connect`, normalizedRelayUrl(relayUrl)) @@ -58,9 +78,10 @@ export function parseRelayMessage(value: string): RelayToHostMessage | null { try { const message = JSON.parse(value) as Partial if (message.type === "ready" && typeof message.protocol === "number") return message as RelayToHostMessage - if (message.type === "tunnel.open" && typeof message.id === "string") return message as RelayToHostMessage - if (message.type === "tunnel.close" && typeof message.id === "string") return message as RelayToHostMessage - if (message.type === "tunnel.message" && typeof message.id === "string" && typeof message.data === "string" && typeof message.binary === "boolean") { + if (!validMessageId((message as { id?: unknown }).id)) return null + if (message.type === "tunnel.open") return message as RelayToHostMessage + if (message.type === "tunnel.close" && validCloseMetadata(message.code, message.reason)) return message as RelayToHostMessage + if (message.type === "tunnel.message" && typeof message.data === "string" && typeof message.binary === "boolean") { return message as RelayToHostMessage } return null @@ -72,16 +93,15 @@ export function parseRelayMessage(value: string): RelayToHostMessage | null { export function parseClientMessage(value: string): ClientToHostMessage | null { try { const message = JSON.parse(value) as Partial - if (typeof message.id !== "string" || !message.id || typeof message.type !== "string") return null + if (!validMessageId(message.id) || typeof message.type !== "string") return null if (message.type === "http.cancel") return message as ClientToHostMessage - if (message.type === "socket.close") return message as ClientToHostMessage + if (message.type === "socket.close" && validClientCloseMetadata(message.code, message.reason)) return message as ClientToHostMessage if (message.type === "socket.message" && typeof message.data === "string" && typeof message.binary === "boolean") return message as ClientToHostMessage - if (message.type === "http.request" && typeof message.method === "string" && typeof message.path === "string" && validHeaders(message.headers)) { + if (message.type === "http.request" && validMethod(message.method) && validPath(message.path) && validHeaders(message.headers)) { if (message.body !== undefined && typeof message.body !== "string") return null return message as ClientToHostMessage } - if (message.type === "socket.open" && typeof message.path === "string" && validHeaders(message.headers) && Array.isArray(message.protocols)) { - if (!message.protocols.every((protocol) => typeof protocol === "string")) return null + if (message.type === "socket.open" && validPath(message.path) && validHeaders(message.headers) && validProtocols(message.protocols)) { return message as ClientToHostMessage } return null @@ -97,7 +117,7 @@ export function base64ByteLength(value: string): number { } export function validCloseCode(value: number | undefined): value is number { - return value === 1000 || (typeof value === "number" && value >= 3000 && value <= 4999) + return value === 1000 || (typeof value === "number" && Number.isSafeInteger(value) && value >= 3000 && value <= 4999) } function isLoopbackHostname(hostname: string): boolean { @@ -106,8 +126,19 @@ function isLoopbackHostname(hostname: string): boolean { } function validHeaders(value: unknown): value is HeaderEntries { - return Array.isArray(value) && value.length <= 256 - && value.every((entry) => Array.isArray(entry) && entry.length === 2 && entry.every((item) => typeof item === "string")) + if (!Array.isArray(value) || value.length > MAX_HEADER_ENTRIES) return false + let bytes = 0 + return value.every((entry) => { + if (!Array.isArray(entry) || entry.length !== 2) return false + const [name, headerValue] = entry as unknown[] + if (typeof name !== "string" || typeof headerValue !== "string" + || !name || name.length > MAX_HEADER_NAME_CHARS || !HEADER_NAME_PATTERN.test(name) + || /[\0\r\n]/.test(headerValue)) return false + const valueBytes = new TextEncoder().encode(headerValue).byteLength + if (valueBytes > MAX_HEADER_VALUE_BYTES) return false + bytes += name.length + valueBytes + return bytes <= MAX_HEADER_BYTES + }) } function blockedRequestHeader(name: string): boolean { @@ -118,3 +149,36 @@ function blockedRequestHeader(name: string): boolean { || normalized.startsWith("x-codenomad-") || normalized.startsWith("x-forwarded-") } + +function validMessageId(value: unknown): value is string { + return typeof value === "string" && MESSAGE_ID_PATTERN.test(value) +} + +function validMethod(value: unknown): value is string { + return typeof value === "string" && value.length <= 16 && /^[A-Za-z]+$/.test(value) +} + +function validPath(value: unknown): value is string { + return typeof value === "string" && value.length > 1 && value.length <= MAX_PATH_CHARS && value.startsWith("/") +} + +function validProtocols(value: unknown): value is string[] { + return Array.isArray(value) && value.length <= MAX_PROTOCOLS + && new Set(value).size === value.length + && value.every((protocol) => typeof protocol === "string" && protocol.length <= MAX_PROTOCOL_CHARS && PROTOCOL_PATTERN.test(protocol)) +} + +function validClientCloseMetadata(code: unknown, reason: unknown): boolean { + return (code === undefined || validCloseCode(typeof code === "number" ? code : undefined)) + && validCloseReason(reason) +} + +function validCloseMetadata(code: unknown, reason: unknown): boolean { + return (code === undefined || (typeof code === "number" && Number.isSafeInteger(code) && code >= 0 && code <= 0xffff)) + && validCloseReason(reason) +} + +function validCloseReason(reason: unknown): boolean { + return reason === undefined || (typeof reason === "string" && reason.length <= MAX_CLOSE_REASON_CHARS + && new TextEncoder().encode(reason).byteLength <= 123 && !/[\0\r\n]/.test(reason)) +} diff --git a/packages/server/src/remote-control/connector.test.ts b/packages/server/src/remote-control/connector.test.ts index 74db9a46b..7d31034ed 100644 --- a/packages/server/src/remote-control/connector.test.ts +++ b/packages/server/src/remote-control/connector.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import test from "node:test" import { normalizedRelayUrl } from "./connector" -import { allowedRemotePath, localHeaders } from "./connector-protocol" +import { allowedRemotePath, localHeaders, parseClientMessage, parseRelayMessage } from "./connector-protocol" test("Remote Control relays require HTTPS except for loopback development", () => { assert.equal(normalizedRelayUrl("https://relay.example/path?ignored=1").href, "https://relay.example/") @@ -16,12 +16,18 @@ test("Remote Control replaces remote credentials and forwarding metadata with ho ["authorization", "Bearer remote"], ["cookie", "remote=session"], ["origin", "https://remote.example"], + ["content-length", "999"], + ["cf-connecting-ip", "203.0.113.1"], + ["x-real-ip", "203.0.113.2"], ["x-forwarded-host", "attacker.example"], ["x-codenomad-remote-control", "0"], ["content-type", "application/json"], ], "local=session") assert.equal(headers.get("authorization"), null) assert.equal(headers.get("origin"), null) + assert.equal(headers.get("content-length"), null) + assert.equal(headers.get("cf-connecting-ip"), null) + assert.equal(headers.get("x-real-ip"), null) assert.equal(headers.get("x-forwarded-host"), null) assert.equal(headers.get("cookie"), "local=session") assert.equal(headers.get("x-codenomad-remote-control"), "1") @@ -35,3 +41,25 @@ test("Remote Control reaches only API and workspace namespaces", () => { assert.equal(allowedRemotePath("/assets/app.js"), false) assert.equal(allowedRemotePath("//attacker.example/api/events"), false) }) + +test("Remote Control accepts only bounded UUID-addressed tunnel contracts", () => { + const id = "123e4567-e89b-42d3-a456-426614174000" + assert.ok(parseRelayMessage(JSON.stringify({ type: "tunnel.open", id }))) + assert.equal(parseRelayMessage(JSON.stringify({ type: "tunnel.open", id: "x".repeat(10_000) })), null) + assert.ok(parseClientMessage(JSON.stringify({ + type: "http.request", id, method: "POST", path: "/api/items", headers: [["content-type", "application/json"]], + }))) + assert.equal(parseClientMessage(JSON.stringify({ + type: "http.request", id, method: "POST", path: `/api/${"x".repeat(9_000)}`, headers: [], + })), null) + assert.equal(parseClientMessage(JSON.stringify({ + type: "http.request", id, method: "POST", path: "/api/items", headers: [["x-large", "x".repeat(17_000)]], + })), null) + assert.equal(parseClientMessage(JSON.stringify({ + type: "socket.open", id, path: "/api/socket", headers: [], protocols: Array.from({ length: 17 }, () => "v1"), + })), null) + assert.equal(parseClientMessage(JSON.stringify({ + type: "socket.open", id, path: "/api/socket", headers: [], protocols: ["v1", "v1"], + })), null) + assert.equal(parseClientMessage(JSON.stringify({ type: "socket.close", id, code: 1006 })), null) +}) diff --git a/packages/server/src/remote-control/connector.ts b/packages/server/src/remote-control/connector.ts index 47039c4ce..fbc942d63 100644 --- a/packages/server/src/remote-control/connector.ts +++ b/packages/server/src/remote-control/connector.ts @@ -3,7 +3,10 @@ import { REMOTE_CONTROL_HEARTBEAT_RESPONSE, REMOTE_CONTROL_MAX_HANDSHAKE_BYTES, REMOTE_CONTROL_MAX_HTTP_BODY_BYTES, + REMOTE_CONTROL_MAX_PLAINTEXT_BYTES, + REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES, REMOTE_CONTROL_PROTOCOL_VERSION, + FrameBudget, createHostHandshake, decodeBase64, encodeBase64, @@ -35,6 +38,11 @@ const MAX_ACTIVE_HTTP_REQUESTS = 32 const MAX_LOCAL_SOCKETS = 16 const MAX_QUEUED_SOCKET_MESSAGES = 64 const MAX_QUEUED_SOCKET_BYTES = 1024 * 1024 +const MAX_PENDING_TUNNEL_FRAMES = 128 +const MAX_PENDING_TUNNEL_BYTES = 24 * 1024 * 1024 +const MAX_LOCAL_SOCKET_BUFFERED_BYTES = 24 * 1024 * 1024 +const MAX_RELAY_BUFFERED_BYTES = 32 * 1024 * 1024 +const MAX_RELAY_MESSAGE_BYTES = Math.ceil((REMOTE_CONTROL_MAX_PLAINTEXT_BYTES + 1024) * 4 / 3) + 2 * 1024 const RELAY_HANDSHAKE_TIMEOUT_MS = 15_000 const HEARTBEAT_INTERVAL_MS = 30_000 const HEARTBEAT_TIMEOUT_MS = 70_000 @@ -57,6 +65,8 @@ interface TunnelState { channel?: EncryptedChannel receiveQueue: Promise sendQueue: Promise + receiveBudget: FrameBudget + sendBudget: FrameBudget httpRequests: Map localSockets: Map> localSocketQueues: Map> @@ -116,7 +126,6 @@ export class RemoteControlConnector { this.ready = false socket.addEventListener("open", () => { if (this.socket !== socket) return - this.reconnectDelay = INITIAL_RECONNECT_MS this.sendRelay({ type: "ready", protocol: REMOTE_CONTROL_PROTOCOL_VERSION }) this.handshakeTimer = setTimeout(() => socket.close(1002, "Remote Control relay handshake timed out"), RELAY_HANDSHAKE_TIMEOUT_MS) this.handshakeTimer.unref() @@ -151,6 +160,11 @@ export class RemoteControlConnector { private onRelayMessage(socket: InstanceType, data: unknown): void { if (this.socket !== socket) return + const byteLength = typeof data === "string" ? data.length : data instanceof ArrayBuffer ? data.byteLength : 0 + if (byteLength > MAX_RELAY_MESSAGE_BYTES) { + socket.close(1009, "Remote Control relay message is too large") + return + } const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? new TextDecoder().decode(data) : "" if (!text) return if (text === REMOTE_CONTROL_HEARTBEAT_RESPONSE) { @@ -170,6 +184,7 @@ export class RemoteControlConnector { if (this.handshakeTimer) clearTimeout(this.handshakeTimer) this.handshakeTimer = null this.ready = true + this.reconnectDelay = INITIAL_RECONNECT_MS this.startHeartbeat() this.options.onState("connected") return @@ -188,8 +203,14 @@ export class RemoteControlConnector { } const tunnel = this.tunnels.get(message.id) if (!tunnel) return + const release = tunnel.receiveBudget.reserve(Math.max(0, base64ByteLength(message.data))) + if (!release) { + this.failTunnel(message.id, new Error("Remote Control receive queue exceeded its safety limit")) + return + } tunnel.receiveQueue = tunnel.receiveQueue .then(() => this.handleTunnelFrame(message.id, message.data, message.binary)) + .finally(release) .catch((error) => this.failTunnel(message.id, error)) } @@ -202,6 +223,8 @@ export class RemoteControlConnector { handshake: createHostHandshake(this.options.encryptionPrivateKey), receiveQueue: Promise.resolve(), sendQueue: Promise.resolve(), + receiveBudget: new FrameBudget(MAX_PENDING_TUNNEL_FRAMES, MAX_PENDING_TUNNEL_BYTES), + sendBudget: new FrameBudget(MAX_PENDING_TUNNEL_FRAMES, MAX_PENDING_TUNNEL_BYTES), httpRequests: new Map(), localSockets: new Map(), localSocketQueues: new Map(), @@ -227,7 +250,7 @@ export class RemoteControlConnector { if (message.type === "http.request") void this.handleHttp(tunnelId, tunnel, message).catch((error) => this.failTunnel(tunnelId, error)) else if (message.type === "http.cancel") this.cancelHttp(tunnel, message.id) else if (message.type === "socket.open") this.openLocalSocket(tunnelId, tunnel, message) - else if (message.type === "socket.message") this.forwardSocketMessage(tunnel, message) + else if (message.type === "socket.message") this.forwardSocketMessage(tunnelId, tunnel, message) else if (message.type === "socket.close") this.closeLocalSocket(tunnel, message.id, message.code, message.reason) } @@ -272,6 +295,8 @@ export class RemoteControlConnector { if (done) break if (value.byteLength) await this.sendClient(tunnelId, { type: "http.chunk", id: message.id, data: encodeBase64(value) }) } + } else if (response.body) { + await response.body.cancel() } await this.sendClient(tunnelId, { type: "http.end", id: message.id }) } catch (error) { @@ -310,11 +335,15 @@ export class RemoteControlConnector { void this.sendClient(tunnelId, { type: "socket.ready", id: message.id, ...(socket.protocol ? { protocol: socket.protocol } : {}) }) const queued = tunnel.localSocketQueues.get(message.id) ?? [] tunnel.localSocketQueues.delete(message.id) - for (const entry of queued) this.sendLocalSocket(socket, entry.data, entry.binary) + for (const entry of queued) this.sendLocalSocket(tunnelId, tunnel, message.id, socket, entry.data, entry.binary) }) socket.addEventListener("message", (event) => { const binary = typeof event.data !== "string" const bytes = binary ? new Uint8Array(event.data as ArrayBuffer) : new TextEncoder().encode(event.data as string) + if (bytes.byteLength > REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES) { + this.closeLocalSocket(tunnel, message.id, 1009, "Local WebSocket message exceeded the remote safety limit") + return + } void this.sendClient(tunnelId, { type: "socket.message", id: message.id, data: encodeBase64(bytes), binary }) }) socket.addEventListener("close", (event) => { @@ -336,9 +365,13 @@ export class RemoteControlConnector { } } - private forwardSocketMessage(tunnel: TunnelState, message: Extract): void { + private forwardSocketMessage(tunnelId: string, tunnel: TunnelState, message: Extract): void { const socket = tunnel.localSockets.get(message.id) if (!socket) return + if (base64ByteLength(message.data) > REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES) { + this.closeLocalSocket(tunnel, message.id, 1009, "Remote WebSocket message exceeded its safety limit") + return + } if (socket.readyState === WebSocket.CONNECTING) { const queued = tunnel.localSocketQueues.get(message.id) const queuedBytes = queued?.reduce((total, entry) => total + base64ByteLength(entry.data), 0) ?? 0 @@ -348,32 +381,52 @@ export class RemoteControlConnector { } else queued.push({ data: message.data, binary: message.binary }) return } - if (socket.readyState === WebSocket.OPEN) this.sendLocalSocket(socket, message.data, message.binary) + if (socket.readyState === WebSocket.OPEN) this.sendLocalSocket(tunnelId, tunnel, message.id, socket, message.data, message.binary) } - private sendLocalSocket(socket: InstanceType, data: string, binary: boolean): void { - const bytes = decodeBase64(data) - socket.send(binary ? bytes : new TextDecoder().decode(bytes)) + private sendLocalSocket( + tunnelId: string, + tunnel: TunnelState, + socketId: string, + socket: InstanceType, + data: string, + binary: boolean, + ): void { + try { + const bytes = decodeBase64(data) + if (socket.bufferedAmount + bytes.byteLength > MAX_LOCAL_SOCKET_BUFFERED_BYTES) { + this.closeLocalSocket(tunnel, socketId, 1009, "Remote WebSocket buffer exceeded its safety limit") + return + } + socket.send(binary ? bytes : new TextDecoder().decode(bytes)) + } catch (error) { + this.failTunnel(tunnelId, error) + } } private closeLocalSocket(tunnel: TunnelState, id: string, code?: number, reason?: string): void { const socket = tunnel.localSockets.get(id) tunnel.localSockets.delete(id) tunnel.localSocketQueues.delete(id) - socket?.close(validCloseCode(code) ? code : undefined, reason?.slice(0, 120)) + socket?.close(validCloseCode(code) ? code : undefined, boundedCloseReason(reason)) } private sendClient(tunnelId: string, message: HostToClientMessage): Promise { const tunnel = this.tunnels.get(tunnelId) if (!tunnel?.channel) return Promise.resolve() const plaintext = new TextEncoder().encode(JSON.stringify(message)) + const release = tunnel.sendBudget.reserve(plaintext.byteLength) + if (!release) { + this.failTunnel(tunnelId, new Error("Remote Control send queue exceeded its safety limit")) + return Promise.resolve() + } tunnel.sendQueue = tunnel.sendQueue.then(async () => { const channel = tunnel.channel if (!channel || this.tunnels.get(tunnelId) !== tunnel) return const frame = await channel.encrypt(plaintext) if (this.tunnels.get(tunnelId) !== tunnel) return this.sendTunnelFrame(tunnelId, frame, true) - }).catch((error) => this.failTunnel(tunnelId, error)) + }).finally(release).catch((error) => this.failTunnel(tunnelId, error)) return tunnel.sendQueue } @@ -382,10 +435,12 @@ export class RemoteControlConnector { } private failTunnel(id: string, error: unknown): void { + if (!this.tunnels.has(id)) return const reason = error instanceof Error ? error.message : "Encrypted Remote Control tunnel failed" + const closeReason = boundedCloseReason(reason) this.options.logger.warn({ err: error, tunnelId: id }, "Remote Control encrypted tunnel failed") - this.sendRelay({ type: "tunnel.close", id, code: 1008, reason: reason.slice(0, 120) }) - this.closeTunnel(id, 1008, reason) + this.sendRelay({ type: "tunnel.close", id, code: 1008, reason: closeReason }) + this.closeTunnel(id, 1008, closeReason) } private closeTunnel(id: string, code?: number, reason?: string): void { @@ -394,7 +449,7 @@ export class RemoteControlConnector { this.tunnels.delete(id) for (const controller of tunnel.httpRequests.values()) controller.abort() for (const socket of tunnel.localSockets.values()) { - socket.close(validCloseCode(code) ? code : undefined, reason?.slice(0, 120)) + socket.close(validCloseCode(code) ? code : undefined, boundedCloseReason(reason)) } tunnel.httpRequests.clear() tunnel.localSockets.clear() @@ -406,7 +461,15 @@ export class RemoteControlConnector { } private sendRelay(message: HostToRelayMessage): void { - if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)) + const socket = this.socket + if (socket?.readyState !== WebSocket.OPEN) return + const payload = JSON.stringify(message) + if (socket.bufferedAmount + payload.length > MAX_RELAY_BUFFERED_BYTES) { + socket.close(1013, "Remote Control relay send buffer exceeded its safety limit") + this.onClosed(socket, "Remote Control relay send buffer exceeded its safety limit") + return + } + socket.send(payload) } private startHeartbeat(): void { @@ -439,3 +502,16 @@ export class RemoteControlConnector { return target } } + +function boundedCloseReason(value: string | undefined): string | undefined { + if (value === undefined) return undefined + let result = "" + let bytes = 0 + for (const character of value) { + const next = new TextEncoder().encode(character).byteLength + if (bytes + next > 123) break + result += character + bytes += next + } + return result +} diff --git a/packages/server/src/remote-control/manager.ts b/packages/server/src/remote-control/manager.ts index 977329217..5ca8e7b9e 100644 --- a/packages/server/src/remote-control/manager.ts +++ b/packages/server/src/remote-control/manager.ts @@ -9,6 +9,7 @@ import { fetch } from "undici" import type { Logger } from "../logger" import { RemoteControlConnector, normalizedRelayUrl, type ConnectorState } from "./connector" import type { RemoteControlIdentity } from "./identity" +import { parseRelayDevices, parseRelayPairing, readRelayJson, type RelayResponse } from "./relay-response" interface ManagerOptions { identity: RemoteControlIdentity @@ -82,11 +83,8 @@ export class RemoteControlManager { signal: AbortSignal.timeout(10_000), }) if (!response.ok) throw new Error(await relayError(response, "Could not create a pairing link")) - const payload = await response.json() as { token?: unknown; expiresAt?: unknown } - if (typeof payload.token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(payload.token) - || typeof payload.expiresAt !== "string" || !Number.isFinite(Date.parse(payload.expiresAt))) { - throw new Error("Relay returned an invalid pairing link") - } + const payload = parseRelayPairing(await readRelayJson(response)) + if (!payload) throw new Error("Relay returned an invalid pairing link") const origin = remoteOrigin(relay, this.options.identity.hostId) const pairingFragment = encodeBase64(new TextEncoder().encode(JSON.stringify({ protocol: REMOTE_CONTROL_PROTOCOL_VERSION, @@ -98,8 +96,8 @@ export class RemoteControlManager { async devices(): Promise { const response = await this.hostRequest("devices") - const payload = await response.json() as { devices?: unknown } - const devices = Array.isArray(payload.devices) ? payload.devices as RemoteControlDevice[] : [] + const devices = parseRelayDevices(await readRelayJson(response)) + if (!devices) throw new Error("Relay returned an invalid remote device list") this.pairedDevices = devices.length return devices } @@ -148,7 +146,8 @@ function remoteOrigin(relay: URL, hostId: string): string { return `${relay.protocol}//${hostId}.${relay.host}` } -async function relayError(response: { json: () => Promise; status: number }, fallback: string): Promise { - const payload = await response.json().catch(() => null) as { error?: unknown } | null - return typeof payload?.error === "string" ? payload.error : `${fallback} (HTTP ${response.status})` +async function relayError(response: RelayResponse, fallback: string): Promise { + const payload = await readRelayJson(response).catch(() => null) + const error = typeof payload === "object" && payload !== null ? (payload as { error?: unknown }).error : undefined + return typeof error === "string" && error.length <= 512 ? error : `${fallback} (HTTP ${response.status})` } diff --git a/packages/server/src/remote-control/relay-response.test.ts b/packages/server/src/remote-control/relay-response.test.ts new file mode 100644 index 000000000..069d32200 --- /dev/null +++ b/packages/server/src/remote-control/relay-response.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { parseRelayDevices, parseRelayPairing, readRelayJson } from "./relay-response" + +test("Remote Control bounds relay control responses before parsing JSON", async () => { + const oversized = new Response("{}", { headers: { "content-length": String(129 * 1024) } }) + await assert.rejects(() => readRelayJson(oversized), /too large/) + + const streamed = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(128 * 1024)) + controller.enqueue(Uint8Array.of(1)) + }, + })) + await assert.rejects(() => readRelayJson(streamed), /too large/) +}) + +test("Remote Control validates pairing and device control metadata", () => { + const id = "123e4567-e89b-42d3-a456-426614174000" + assert.deepEqual(parseRelayPairing({ token: "a".repeat(43), expiresAt: "2026-09-05T12:00:00.000Z" }), { + token: "a".repeat(43), + expiresAt: "2026-09-05T12:00:00.000Z", + }) + assert.equal(parseRelayPairing({ token: "short", expiresAt: "later" }), null) + assert.deepEqual(parseRelayDevices({ devices: [{ + id, + name: "Browser", + createdAt: "2026-09-05T12:00:00.000Z", + lastSeenAt: "2026-09-05T12:01:00.000Z", + }] })?.map((device) => device.id), [id]) + assert.equal(parseRelayDevices({ devices: Array.from({ length: 65 }, () => ({ + id, + name: "Browser", + createdAt: "2026-09-05T12:00:00.000Z", + lastSeenAt: "2026-09-05T12:01:00.000Z", + })) }), null) +}) diff --git a/packages/server/src/remote-control/relay-response.ts b/packages/server/src/remote-control/relay-response.ts new file mode 100644 index 000000000..15cffaa29 --- /dev/null +++ b/packages/server/src/remote-control/relay-response.ts @@ -0,0 +1,86 @@ +import type { RemoteControlDevice } from "@codenomad/remote-control-protocol" + +const MAX_CONTROL_RESPONSE_BYTES = 128 * 1024 +const MAX_CONTROL_RESPONSE_CHUNKS = 1024 +const MAX_DEVICES = 64 +const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/ + +export interface RelayPairingMetadata { + token: string + expiresAt: string +} + +export interface RelayResponse { + status: number + headers: { get(name: string): string | null } + body: { + cancel(): Promise + getReader(): { + read(): Promise<{ done: boolean; value?: Uint8Array }> + cancel(): Promise + } + } | null +} + +export async function readRelayJson(response: RelayResponse): Promise { + const declaredLength = Number(response.headers.get("content-length")) + if (Number.isFinite(declaredLength) && declaredLength > MAX_CONTROL_RESPONSE_BYTES) { + await response.body?.cancel().catch(() => undefined) + throw new Error("Remote Control relay response is too large") + } + if (!response.body) throw new Error("Remote Control relay returned an empty response") + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const { done, value } = await reader.read() + if (done || !value) break + if (!value.byteLength) continue + size += value.byteLength + if (size > MAX_CONTROL_RESPONSE_BYTES || chunks.length >= MAX_CONTROL_RESPONSE_CHUNKS) { + await reader.cancel().catch(() => undefined) + throw new Error("Remote Control relay response is too large") + } + chunks.push(value) + } + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown +} + +export function parseRelayPairing(value: unknown): RelayPairingMetadata | null { + if (!isRecord(value) || typeof value.token !== "string" || !TOKEN_PATTERN.test(value.token) + || typeof value.expiresAt !== "string" || value.expiresAt.length > 64 + || !Number.isFinite(Date.parse(value.expiresAt))) return null + return { token: value.token, expiresAt: value.expiresAt } +} + +export function parseRelayDevices(value: unknown): RemoteControlDevice[] | null { + if (!isRecord(value) || !Array.isArray(value.devices) || value.devices.length > MAX_DEVICES) return null + const ids = new Set() + const devices: RemoteControlDevice[] = [] + for (const candidate of value.devices) { + if (!isRecord(candidate) || typeof candidate.id !== "string" || !DEVICE_ID_PATTERN.test(candidate.id) + || ids.has(candidate.id) || typeof candidate.name !== "string" || !candidate.name.trim() + || candidate.name.length > 80 || typeof candidate.createdAt !== "string" || candidate.createdAt.length > 64 + || typeof candidate.lastSeenAt !== "string" || candidate.lastSeenAt.length > 64 + || !Number.isFinite(Date.parse(candidate.createdAt)) || !Number.isFinite(Date.parse(candidate.lastSeenAt))) return null + ids.add(candidate.id) + devices.push({ + id: candidate.id, + name: candidate.name, + createdAt: candidate.createdAt, + lastSeenAt: candidate.lastSeenAt, + }) + } + return devices +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/server/src/server/__tests__/listener-base-url.test.ts b/packages/server/src/server/__tests__/listener-base-url.test.ts index 289fe3c5c..ddd3161a2 100644 --- a/packages/server/src/server/__tests__/listener-base-url.test.ts +++ b/packages/server/src/server/__tests__/listener-base-url.test.ts @@ -8,24 +8,27 @@ describe("resolvePluginBaseUrl", () => { assert.equal( resolvePluginBaseUrl({ httpsStart: { protocol: "https", bindHost: "127.0.0.1", port: 9898 }, + remoteUrl: "https://localhost:9898", }), "https://127.0.0.1:9898", ) }) - it("uses the concrete HTTPS listener when no HTTP listener exists", () => { + it("uses the concrete LAN listener when no loopback listener exists", () => { assert.equal( resolvePluginBaseUrl({ httpsStart: { protocol: "https", bindHost: "192.168.1.25", port: 9898 }, + remoteUrl: "https://192.168.1.25:9898", }), "https://192.168.1.25:9898", ) }) - it("resolves wildcard listeners to their loopback URL", () => { + it("prefers loopback for wildcard listeners because 0.0.0.0 accepts loopback", () => { assert.equal( resolvePluginBaseUrl({ httpsStart: { protocol: "https", bindHost: "0.0.0.0", port: 9898 }, + remoteUrl: "https://192.168.1.25:9898", }), "https://127.0.0.1:9898", ) @@ -36,6 +39,7 @@ describe("resolvePluginBaseUrl", () => { resolvePluginBaseUrl({ httpStart: { protocol: "http", bindHost: "127.0.0.1", port: 9899 }, httpsStart: { protocol: "https", bindHost: "192.168.1.25", port: 9898 }, + remoteUrl: "https://192.168.1.25:9898", }), "http://127.0.0.1:9899", ) diff --git a/packages/server/src/server/__tests__/network-addresses.test.ts b/packages/server/src/server/__tests__/network-addresses.test.ts new file mode 100644 index 000000000..a6d477670 --- /dev/null +++ b/packages/server/src/server/__tests__/network-addresses.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict" +import os from "node:os" +import { describe, it } from "node:test" + +import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses" + +describe("resolveNetworkAddresses", () => { + it("preserves interface order among external addresses", () => { + const addresses = [ + { address: "172.24.0.1", family: "IPv4", internal: false }, + { address: "192.168.1.128", family: "IPv4", internal: false }, + { address: "10.0.0.8", family: 4, internal: false }, + { address: "127.0.0.1", family: "IPv4", internal: true }, + { address: "169.254.10.20", family: "IPv4", internal: false }, + ] + + usingMockedNetworkInterfaces(addresses, () => { + const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + + assert.deepEqual( + result.map((entry) => entry.ip), + ["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"], + ) + }) + }) +}) + +describe("resolveRemoteAddresses", () => { + it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => { + const addresses = [ + { address: "169.254.10.20", family: "IPv4", internal: false }, + { address: "192.168.1.128", family: "IPv4", internal: false }, + { address: "172.24.0.1", family: "IPv4", internal: false }, + ] + + usingMockedNetworkInterfaces(addresses, () => { + const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + + assert.deepEqual( + result.userVisible.map((entry) => entry.ip), + ["192.168.1.128", "172.24.0.1", "169.254.10.20"], + ) + assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898") + }) + }) + + it("prefers private LAN addresses over public addresses", () => { + const addresses = [ + { address: "203.0.113.40", family: "IPv4", internal: false }, + { address: "192.168.1.128", family: "IPv4", internal: false }, + { address: "8.8.8.8", family: "IPv4", internal: false }, + ] + + usingMockedNetworkInterfaces(addresses, () => { + const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + + assert.deepEqual( + result.userVisible.map((entry) => entry.ip), + ["192.168.1.128", "203.0.113.40", "8.8.8.8"], + ) + assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898") + }) + }) + + it("uses a public address when no private LAN address is available", () => { + const addresses = [ + { address: "169.254.10.20", family: "IPv4", internal: false }, + { address: "203.0.113.40", family: "IPv4", internal: false }, + ] + + usingMockedNetworkInterfaces(addresses, () => { + const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + + assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"]) + assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898") + }) + }) +}) + +function usingMockedNetworkInterfaces( + addresses: Array<{ address: string; family: string | number; internal: boolean }>, + callback: () => void, +) { + const original = os.networkInterfaces + os.networkInterfaces = (() => ({ + ethernet0: addresses as unknown as ReturnType[string], + })) as typeof os.networkInterfaces + + try { + callback() + } finally { + os.networkInterfaces = original + } +} diff --git a/packages/server/src/server/__tests__/remote-proxy.test.ts b/packages/server/src/server/__tests__/remote-proxy.test.ts new file mode 100644 index 000000000..f4e5053d9 --- /dev/null +++ b/packages/server/src/server/__tests__/remote-proxy.test.ts @@ -0,0 +1,328 @@ +import assert from "node:assert/strict" +import { after, afterEach, describe, it } from "node:test" +import fs from "node:fs" +import http, { type IncomingMessage, type ServerResponse } from "node:http" +import os from "node:os" +import path from "node:path" + +import { Agent, fetch } from "undici" + +import type { AuthManager } from "../../auth/manager" +import type { Logger } from "../../logger" +import { RemoteProxySessionManager } from "../remote-proxy" +import { resolveHttpsOptions } from "../tls" + +const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-")) +const sharedTls = resolveHttpsOptions({ enabled: true, configDir: sharedTempDir, host: "127.0.0.1", logger: createStubLogger() }) +if (!sharedTls) throw new Error("Failed to generate HTTPS options for remote proxy tests") +const sharedHttpsOptions = sharedTls.httpsOptions +const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } }) +const managers = new Set() + +afterEach(async () => { + for (const manager of managers) await manager.shutdown().catch(() => undefined) + managers.clear() +}) + +after(async () => { + fs.rmSync(sharedTempDir, { recursive: true, force: true }) + await httpsDispatcher.destroy().catch(() => {}) +}) + +describe("RemoteProxySessionManager", () => { + it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const session1 = await createSession(manager, `${upstreamBaseUrl}/base`) + const session2 = await createSession(manager, `${upstreamBaseUrl}/base`) + const blocked = await proxyFetch(`${session1.proxyOrigin}/status`) + assert.equal(blocked.status, 403) + const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: session2.token }), + }) + assert.equal(wrongTokenResponse.status, 401) + + assert.equal(await activateSession(session1), true) + assert.equal(await activateSession(session2), true) + }, (req, res) => { + res.writeHead(200, { "content-type": "text/plain" }) + res.end(req.url ?? "") + }) + }) + + it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const session = await createSession(manager, `${upstreamBaseUrl}/base`) + await activateSession(session) + const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`) + assert.equal(apiResponse.status, 200) + assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar") + + const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" }) + assert.equal(redirectResponse.status, 302) + assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`) + }, (req, res) => { + const requestUrl = req.url ?? "" + if (requestUrl === "/base/redirect") { + res.writeHead(302, { location: "/base/after?ok=1" }) + return res.end() + } + res.writeHead(200, { "content-type": "text/plain" }) + res.end(requestUrl) + }) + }) + + it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const session = await createSession(manager, `${upstreamBaseUrl}/base`) + await activateSession(session) + const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`) + assert.equal(loginResponse.status, 200) + const setCookie = getSetCookie(loginResponse)[0] + + assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i) + assert.doesNotMatch(setCookie, /domain=/i) + const cookieHeader = setCookie.split(";", 1)[0] + const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, { + headers: { cookie: cookieHeader }, + }) + assert.equal(await whoamiResponse.text(), "session=abc123") + }, (req, res) => { + const requestUrl = req.url ?? "" + if (requestUrl === "/base/login") { + res.writeHead(200, { + "content-type": "text/plain", + "set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1", + }) + return res.end("ok") + } + if (requestUrl === "/base/whoami") { + res.writeHead(200, { "content-type": "text/plain" }) + return res.end(req.headers.cookie ?? "") + } + res.writeHead(404, { "content-type": "text/plain" }) + res.end(requestUrl) + }) + }) + + it("supports explicit deletion and idle cleanup of sessions", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const session = await createSession(manager, `${upstreamBaseUrl}/base`) + assert.equal(await manager.deleteSession(session.sessionId), true) + assert.equal(await manager.deleteSession(session.sessionId), false) + const session3 = await createSession(manager, `${upstreamBaseUrl}/base`) + const internalSessions = (manager as any).sessions as Map + const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise + internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000 + await internalCleanup.call(manager) + assert.equal(internalSessions.has(session3.sessionId), false) + assert.equal(await manager.deleteSession(session3.sessionId), false) + }, (_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }) + res.end("ok") + }) + }) + + it("closes every session listener during shutdown", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const first = await createSession(manager, `${upstreamBaseUrl}/base`) + await createSession(manager, `${upstreamBaseUrl}/other`) + await manager.shutdown() + assert.equal((manager as any).sessions.size, 0) + await assert.rejects(proxyFetch(`${first.proxyOrigin}/status`)) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) + + it("waits for in-flight idle cleanup during shutdown", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager({ disposalTimeoutMs: 1_000 }) + const session = await createSession(manager, `${upstreamBaseUrl}/base`) + const internalSession = (manager as any).sessions.get(session.sessionId) + const closeGate = deferred() + const originalClose = internalSession.app.close.bind(internalSession.app) + internalSession.app.close = async () => { + await closeGate.promise + return originalClose() + } + internalSession.lastAccessAt = Date.now() - 31 * 60_000 + const cleanup = (manager as any).cleanupExpiredSessions() as Promise + let shutdownSettled = false + const shutdown = manager.shutdown().then(() => { + shutdownSettled = true + }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(shutdownSettled, false) + closeGate.resolve() + await cleanup + await shutdown + assert.equal((manager as any).disposals.size, 0) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) + + it("aborts a stalled event stream during bounded shutdown", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager({ disposalTimeoutMs: 100 }) + const session = await createSession(manager, `${upstreamBaseUrl}/base`) + await activateSession(session) + const response = await proxyFetch(`${session.proxyOrigin}/events`) + assert.equal(response.status, 200) + await Promise.race([ + manager.shutdown(), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)), + ]) + assert.equal((manager as any).sessions.size, 0) + assert.equal((manager as any).disposals.size, 0) + }, (req, res) => { + if (req.url === "/base/events") { + res.writeHead(200, { "content-type": "text/event-stream" }) + return void res.write("data: connected\n\n") + } + res.writeHead(200).end("ok") + }) + }) + + it("surfaces listener disposal failures", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const session = await createSession(manager, `${upstreamBaseUrl}/base`) + const internalSession = (manager as any).sessions.get(session.sessionId) + const originalClose = internalSession.app.close.bind(internalSession.app) + let failClose = true + internalSession.app.close = async () => { + await originalClose() + if (failClose) { failClose = false; throw new Error("close failed") } + } + await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/) + // A completed deletion failure predating shutdown must not poison it. + await manager.shutdown() + assert.equal((manager as any).sessions.size, 0) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) + + it("waits for in-flight creation and rejects sessions that cross shutdown", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const creation = manager.createSession(`${upstreamBaseUrl}/base`, false) + const shutdown = manager.shutdown() + await assert.rejects(creation, /shutting down/) + await shutdown + assert.equal((manager as any).creations.size, 0) + assert.equal((manager as any).sessions.size, 0) + await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) + + it("coalesces shutdown, retains current disposal failures, and gives every session its own agent", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const verified = await manager.createSession(`${upstreamBaseUrl}/verified`, false) + const insecure = await manager.createSession(`${upstreamBaseUrl}/insecure`, true) + const sessions = (manager as any).sessions as Map + assert.ok(sessions.get(verified.sessionId).dispatcher instanceof Agent) + assert.ok(sessions.get(insecure.sessionId).dispatcher instanceof Agent) + assert.notStrictEqual(sessions.get(verified.sessionId).dispatcher, sessions.get(insecure.sessionId).dispatcher) + + const closeGate = deferred() + const originalClose = sessions.get(verified.sessionId).app.close.bind(sessions.get(verified.sessionId).app) + let failClose = true + sessions.get(verified.sessionId).app.close = async () => { + await closeGate.promise + await originalClose() + if (failClose) { failClose = false; throw new Error("current close failed") } + } + const disposal = manager.deleteSession(verified.sessionId); const first = manager.shutdown() + const concurrent = manager.shutdown() + assert.strictEqual(first, concurrent) + closeGate.resolve() + await assert.rejects(disposal, /Remote proxy disposal failed/) + await assert.rejects(first, (error: unknown) => error instanceof AggregateError && error.errors.some((cause) => + cause instanceof AggregateError && cause.errors.some((nested) => /current close failed/.test(String(nested))))) + await manager.shutdown() + assert.equal(sessions.size, 0) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) +}) + +function createSessionManager(options: { disposalTimeoutMs?: number } = {}) { + const manager = new RemoteProxySessionManager({ + authManager: { isLoopbackRequest: () => true } as unknown as AuthManager, + logger: createStubLogger(), httpsOptions: sharedHttpsOptions, ...options, + }) + managers.add(manager) + return manager +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) + return { promise, resolve } +} + +async function createSession(manager: RemoteProxySessionManager, baseUrl: string) { + const created = await manager.createSession(baseUrl, false) + const windowUrl = new URL(created.windowUrl) + return { + sessionId: created.sessionId, + windowUrl, + proxyOrigin: windowUrl.origin, + token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")), + } +} + +async function activateSession(session: { proxyOrigin: string; token: string }) { + const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: session.token }), + }) + if (!response.ok) return false + const body = (await response.json()) as { ok?: boolean } + return body.ok === true +} + +function getSetCookie(response: Awaited>): string[] { + const values = (response.headers as any).getSetCookie?.() as string[] | undefined + if (Array.isArray(values) && values.length > 0) return values + const fallback = response.headers.get("set-cookie") + return fallback ? [fallback] : [] +} + +async function proxyFetch(url: string, init?: Parameters[1]) { + return fetch(url, { dispatcher: httpsDispatcher, ...init }) +} + +async function withUpstreamServer( + callback: (baseUrl: string) => Promise, + handler: (req: IncomingMessage, res: ServerResponse) => void, +) { + const server = http.createServer(handler) + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())) + try { + const address = server.address() + if (!address || typeof address === "string") throw new Error("Failed to resolve upstream server address") + await callback(`http://127.0.0.1:${address.port}`) + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } +} + +function createStubLogger(): Logger { + const logger = { info() {}, warn() {}, error() {}, child() { return logger } } + return logger as unknown as Logger +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index bd670dee0..d5293f4bc 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -27,6 +27,8 @@ import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" import { registerOpenCodeUpdateRoutes } from "./routes/opencode-update" import { registerRemoteControlRoutes } from "./routes/remote-control" +import { registerRemoteServerRoutes } from "./routes/remote-servers" +import { registerRemoteProxyRoutes } from "./routes/remote-proxy" import { registerSideCarRoutes } from "./routes/sidecars" import { registerPreviewRoutes } from "./routes/previews" import { registerUsageRoutes } from "./routes/usage" @@ -40,8 +42,9 @@ import type { SpeechService } from "../speech/service" import { ClientConnectionManager } from "../clients/connection-manager" import type { SideCarManager } from "../sidecars/manager" import type { PreviewManager } from "../previews/manager" -import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge" import type { RemoteControlManager } from "../remote-control/manager" +import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge" +import type { RemoteProxySessionManager } from "./remote-proxy" import { createOpenCodeUpdateService } from "../opencode-update/service" import { WorktreeDeletionFence } from "../workspaces/worktree-session-evacuation" import type { NativeParent } from "../native-parent" @@ -64,9 +67,10 @@ interface HttpServerDeps { speechService: SpeechService sidecarManager: SideCarManager previewManager: PreviewManager + remoteControlManager: RemoteControlManager authManager: AuthManager clientConnectionManager: ClientConnectionManager - remoteControlManager: RemoteControlManager + remoteProxySessionManager: RemoteProxySessionManager yoloManager: AutoAcceptManager uiStaticDir: string uiDevServerUrl?: string @@ -141,9 +145,11 @@ export function createHttpServer(deps: HttpServerDeps) { }) const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]) + const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") + const getSelfOrigins = (): Set => { const origins = new Set() - const candidates: Array = [deps.serverMeta.localUrl] + const candidates: Array = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl] for (const candidate of candidates) { if (!candidate) continue try { @@ -152,6 +158,13 @@ export function createHttpServer(deps: HttpServerDeps) { // ignore } } + for (const addr of deps.serverMeta.addresses ?? []) { + try { + origins.add(new URL(addr.remoteUrl).origin) + } catch { + // ignore + } + } return origins } @@ -178,6 +191,13 @@ export function createHttpServer(deps: HttpServerDeps) { return } + // When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access. + if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) { + cb(null, true) + return + } + + cb(null, false) }, credentials: true, @@ -205,6 +225,11 @@ export function createHttpServer(deps: HttpServerDeps) { publicPagePaths.add("/auth/token") } + const isLoopbackRemoteProxyDelete = + request.method === "DELETE" && + pathname.startsWith("/api/remote-proxy/sessions/") && + deps.authManager.isLoopbackRequest(request) + const encodedPreviewToken = pathname.match(/^\/previews\/([^/]+)(?:\/|$)/)?.[1] const hostPreviewToken = parsePreviewCapabilityHost(request.headers.host) let isPreviewCapability = false @@ -227,7 +252,7 @@ export function createHttpServer(deps: HttpServerDeps) { authManager: deps.authManager, bridgeToken: deps.automationBridgeToken, }) - if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isPreviewCapability || isAutomationBridge) { + if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete || isPreviewCapability || isAutomationBridge) { done() return } @@ -292,6 +317,8 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, workspaceManager: deps.workspaceManager, }) + registerRemoteServerRoutes(app, { logger: apiLogger }) + registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager }) registerRemoteControlRoutes(app, { manager: deps.remoteControlManager }) registerSpeechRoutes(app, { speechService: deps.speechService }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) diff --git a/packages/server/src/server/listener-base-url.ts b/packages/server/src/server/listener-base-url.ts index 2a081b132..498a710a9 100644 --- a/packages/server/src/server/listener-base-url.ts +++ b/packages/server/src/server/listener-base-url.ts @@ -7,6 +7,7 @@ export interface StartedListenerBaseUrlInput { export interface ResolvePluginBaseUrlInput { httpStart?: StartedListenerBaseUrlInput | null httpsStart?: StartedListenerBaseUrlInput | null + remoteUrl?: string } export function resolvePluginBaseUrl(input: ResolvePluginBaseUrlInput): string { @@ -15,6 +16,10 @@ export function resolvePluginBaseUrl(input: ResolvePluginBaseUrlInput): string { return `${loopbackListener.protocol}://127.0.0.1:${loopbackListener.port}` } + if (input.remoteUrl) { + return input.remoteUrl + } + const fallbackListener = input.httpStart ?? input.httpsStart if (!fallbackListener) { throw new Error("No listeners started") diff --git a/packages/server/src/server/network-addresses.ts b/packages/server/src/server/network-addresses.ts new file mode 100644 index 000000000..8491fc82a --- /dev/null +++ b/packages/server/src/server/network-addresses.ts @@ -0,0 +1,128 @@ +import os from "os" +import type { NetworkAddress } from "../api-types" + +export interface ResolvedRemoteAddresses { + all: NetworkAddress[] + userVisible: NetworkAddress[] + primaryRemoteUrl?: string +} + +export function resolveNetworkAddresses(args: { + host: string + protocol: "http" | "https" + port: number +}): NetworkAddress[] { + const { host, protocol, port } = args + const interfaces = os.networkInterfaces() + const seen = new Set() + const results: NetworkAddress[] = [] + + const addAddress = (ip: string, scope: NetworkAddress["scope"]) => { + if (!ip || ip === "0.0.0.0") return + const key = `ipv4-${ip}` + if (seen.has(key)) return + seen.add(key) + results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` }) + } + + const normalizeFamily = (value: string | number) => { + if (typeof value === "string") { + const lowered = value.toLowerCase() + if (lowered === "ipv4") { + return "ipv4" as const + } + } + if (value === 4) return "ipv4" as const + return null + } + + if (host === "0.0.0.0") { + // Enumerate system interfaces (IPv4 only) + for (const entries of Object.values(interfaces)) { + if (!entries) continue + for (const entry of entries) { + const family = normalizeFamily(entry.family) + if (!family) continue + if (!entry.address || entry.address === "0.0.0.0") continue + const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external" + addAddress(entry.address, scope) + } + } + } + + // Always include loopback address + addAddress("127.0.0.1", "loopback") + + // Include explicitly configured host if it was IPv4 + if (isIPv4Address(host) && host !== "0.0.0.0") { + const isLoopback = host.startsWith("127.") + addAddress(host, isLoopback ? "loopback" : "external") + } + + const scopeWeight: Record = { external: 0, internal: 1, loopback: 2 } + + return results.sort((a, b) => { + const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] + if (scopeDelta !== 0) return scopeDelta + + return 0 + }) +} + +export function resolveRemoteAddresses(args: { + host: string + protocol: "http" | "https" + port: number +}): ResolvedRemoteAddresses { + const all = resolveNetworkAddresses(args) + const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external")) + return { + all, + userVisible, + primaryRemoteUrl: userVisible[0]?.remoteUrl, + } +} + +function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] { + return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip)) +} + +function getUserVisiblePriority(ip: string): number { + if (isPrivateIPv4(ip)) return 0 + if (isLinkLocalIPv4(ip)) return 2 + return 1 +} + +function isLinkLocalIPv4(ip: string): boolean { + const octets = parseIPv4(ip) + if (!octets) return false + const [first, second] = octets + return first === 169 && second === 254 +} + +function isPrivateIPv4(ip: string): boolean { + const octets = parseIPv4(ip) + if (!octets) return false + const [first, second] = octets + + if (first === 10) return true + if (first === 192 && second === 168) return true + return first === 172 && second >= 16 && second <= 31 +} + +function parseIPv4(value: string): number[] | null { + if (!isIPv4Address(value)) return null + return value.split(".").map((part) => Number(part)) +} + +function isIPv4Address(value: string | undefined): value is string { + if (!value) return false + const parts = value.split(".") + if (parts.length !== 4) return false + return parts.every((part) => { + if (part.length === 0 || part.length > 3) return false + if (!/^[0-9]+$/.test(part)) return false + const num = Number(part) + return Number.isInteger(num) && num >= 0 && num <= 255 + }) +} diff --git a/packages/server/src/server/remote-proxy.ts b/packages/server/src/server/remote-proxy.ts new file mode 100644 index 000000000..a4c47aac2 --- /dev/null +++ b/packages/server/src/server/remote-proxy.ts @@ -0,0 +1,621 @@ +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify" +import { randomBytes, randomUUID } from "crypto" +import { Readable } from "stream" +import { pipeline } from "stream/promises" +import { Agent, fetch } from "undici" +import type { AuthManager } from "../auth/manager" +import type { Logger } from "../logger" + +const LOOPBACK_HOST = "127.0.0.1" +const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token" +const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token" +const SESSION_IDLE_TTL_MS = 30 * 60_000 +const SESSION_DISPOSAL_TIMEOUT_MS = 5_000 + +interface RemoteProxySession { + id: string + bootstrapToken: string + targetBaseUrl: URL + localBaseUrl: URL + activated: boolean + cookiePrefix: string + app: FastifyInstance + dispatcher?: Agent + abortController: AbortController + lastAccessAt: number +} + +export interface RemoteProxySessionManagerOptions { + authManager: AuthManager + logger: Logger + httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } + disposalTimeoutMs?: number +} + +export interface RemoteProxySessionCreateResult { + sessionId: string + windowUrl: string +} + +export class RemoteProxySessionManager { + private readonly sessions = new Map() + private readonly creations = new Set>() + private readonly disposals = new Set>() + private readonly sessionDisposals = new Map>() + private readonly cleanupTimer: NodeJS.Timeout + private shuttingDown = false + private shutdownPromise?: Promise + + constructor(private readonly options: RemoteProxySessionManagerOptions) { + this.cleanupTimer = setInterval(() => void this.cleanupExpiredSessions().catch((error) => + this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session")), 60_000) + this.cleanupTimer.unref() + } + + async createSession(baseUrl: string, skipTlsVerify: boolean): Promise { + if (this.shuttingDown) throw new Error("Remote proxy session manager is shutting down") + + return this.track(this.creations, this.createSessionInternal(baseUrl, skipTlsVerify)) + } + + private async createSessionInternal(baseUrl: string, skipTlsVerify: boolean): Promise { + if (!this.options.httpsOptions) { + throw new Error("Local HTTPS is required for remote proxy sessions") + } + + const targetBaseUrl = normalizeBaseUrl(baseUrl) + const sessionId = randomUUID() + const bootstrapToken = randomBytes(32).toString("base64url") + const dispatcher = new Agent(skipTlsVerify ? { connect: { rejectUnauthorized: false } } : {}) + const abortController = new AbortController() + const app = Fastify({ logger: false, https: this.options.httpsOptions, forceCloseConnections: true }) + let session: RemoteProxySession | null = null + + app.removeAllContentTypeParsers() + // Preserve raw request bodies for proxying while still letting token JSON parse from Buffer. + app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body)) + + app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => { + if (!this.options.authManager.isLoopbackRequest(request)) { + reply.code(404).send({ error: "Not found" }) + return + } + + reply.header("Cache-Control", "no-store") + reply.header("Pragma", "no-cache") + reply.header("Expires", "0") + reply.type("text/html").send(buildBootstrapPageHtml()) + }) + + app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => { + if (!this.options.authManager.isLoopbackRequest(request)) { + reply.code(404).send({ error: "Not found" }) + return + } + + if (!session) { + reply.code(503).send({ error: "Remote proxy session is unavailable" }) + return + } + + const body = parseTokenBody(request.body) + if (body.token !== session.bootstrapToken) { + reply.code(401).send({ error: "Invalid token" }) + return + } + + session.activated = true + session.lastAccessAt = Date.now() + reply.send({ ok: true }) + }) + + const handleProxyRequest = async (request: FastifyRequest, reply: FastifyReply) => { + if (!session) { + reply.code(503).send({ error: "Remote proxy session is unavailable" }) + return + } + + if (!session.activated) { + reply.code(403).send({ error: "Remote proxy session is not activated" }) + return + } + + session.lastAccessAt = Date.now() + await proxyRequest({ request, reply, session, logger: this.options.logger }) + } + app.all("/*", handleProxyRequest) + app.setNotFoundHandler(handleProxyRequest) + + const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 }) + const address = new URL(addressInfo) + const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`) + const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl) + const returnTo = buildReturnToTarget(entryUrl) + const bootstrapUrl = `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}` + + session = { + id: sessionId, + bootstrapToken, + targetBaseUrl, + localBaseUrl, + activated: false, + cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`, + app, + dispatcher, + abortController, + lastAccessAt: Date.now(), + } + + this.sessions.set(sessionId, session) + if (this.shuttingDown) { + await this.disposeSession(sessionId) + throw new Error("Remote proxy session manager is shutting down") + } + this.options.logger.info( + { sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() }, + "Created remote proxy session", + ) + + return { sessionId, windowUrl: bootstrapUrl } + } + + async deleteSession(sessionId: string): Promise { + return this.disposeSession(sessionId) + } + + shutdown(): Promise { + if (this.shutdownPromise) return this.shutdownPromise + this.shuttingDown = true + clearInterval(this.cleanupTimer) + const shutdown = this.drainShutdown() + this.shutdownPromise = shutdown + void shutdown.finally(() => { + if (this.shutdownPromise === shutdown) this.shutdownPromise = undefined + }).catch(() => undefined) + return shutdown + } + + private async drainShutdown(): Promise { + const disposals = new Set(this.disposals) + while (this.creations.size > 0) { + await Promise.allSettled([...this.creations]) + for (const disposal of this.disposals) disposals.add(disposal) + } + const pendingResults = await Promise.allSettled(disposals) + const results = await Promise.allSettled(Array.from(this.sessions.keys(), (id) => this.disposeSession(id))) + const failures = [...pendingResults, ...results] + .flatMap((result) => result.status === "rejected" ? [result.reason] : []) + if (failures.length) throw new AggregateError(failures, "Remote proxy shutdown failed") + } + + private async cleanupExpiredSessions() { + const now = Date.now() + for (const session of Array.from(this.sessions.values())) { + if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) { + continue + } + await this.disposeSession(session.id) + } + } + + private disposeSession(sessionId: string): Promise { + const pending = this.sessionDisposals.get(sessionId) + if (pending) return pending + const session = this.sessions.get(sessionId) + if (!session) return Promise.resolve(false) + + session.abortController.abort() + const disposal = this.trackDisposal(this.disposeResources(session.app, session.dispatcher).then(() => { + if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId) + this.options.logger.info({ sessionId }, "Disposed remote proxy session") + return true + })) + this.sessionDisposals.set(sessionId, disposal) + void disposal.finally(() => { + if (this.sessionDisposals.get(sessionId) === disposal) this.sessionDisposals.delete(sessionId) + }).catch(() => undefined) + return disposal + } + + private async disposeResources(app: FastifyInstance, dispatcher?: Agent): Promise { + app.server.closeAllConnections?.() + const results = await Promise.race([ + Promise.allSettled([app.close(), dispatcher?.destroy()]), + new Promise((_resolve, reject) => AbortSignal.timeout( + Math.max(1, this.options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS), + ).addEventListener( + "abort", () => reject(new Error("Remote proxy disposal timed out")), + )), + ]) + const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) + if (failures.length) throw new AggregateError(failures, "Remote proxy disposal failed") + } + + private track(operations: Set>, operation: Promise): Promise { + operations.add(operation) + void operation.finally(() => operations.delete(operation)).catch(() => undefined) + return operation + } + + private trackDisposal(operation: Promise): Promise { + return this.track(this.disposals, operation) + } +} + +function normalizeBaseUrl(input: string): URL { + const parsed = new URL(input.trim()) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Server URL must use http:// or https://") + } + + parsed.hash = "" + parsed.search = "" + parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/" + return parsed +} + +function buildReturnToTarget(entryUrl: URL): string { + const query = entryUrl.search ? entryUrl.search : "" + return `${entryUrl.pathname || "/"}${query}` +} + +function buildBootstrapPageHtml(): string { + return ` + + + + + CodeNomad + + + +
+

Connecting...

+

Finalizing local authentication.

+
+
+ + +` +} + +function parseTokenBody(body: unknown): { token: string } { + const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined + const token = typeof value?.token === "string" ? value.token.trim() : "" + if (!token) { + throw new Error("Missing bootstrap token") + } + return { token } +} + +function normalizeJsonBody(body: unknown): unknown { + if (Buffer.isBuffer(body)) { + return JSON.parse(body.toString("utf-8")) + } + if (typeof body === "string") { + return JSON.parse(body) + } + return body +} + +function toRequestBody(body: unknown): any { + if (body == null) { + return undefined + } + if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) { + return body + } + return JSON.stringify(body) +} + +async function proxyRequest(args: { + request: FastifyRequest + reply: FastifyReply + session: RemoteProxySession + logger: Logger +}) { + const { request, reply, session, logger } = args + const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url) + const headers = filterRequestHeaders(request.headers, session) + + const init: any = { + method: request.method, + headers, + dispatcher: session.dispatcher, + signal: session.abortController.signal, + redirect: "manual", + } + + if (request.method !== "GET" && request.method !== "HEAD") { + const body = toRequestBody(request.body) + if (body !== undefined) { + init.body = body + init.duplex = "half" + } + } + + try { + const response = await fetch(upstreamUrl, init as any) + reply.code(response.status) + applyResponseHeaders(reply, response, session) + + if (!response.body || request.method === "HEAD") { + reply.send() + return + } + + reply.hijack() + reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders())) + await pipeline(Readable.fromWeb(response.body as any), reply.raw) + } catch (error) { + logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request") + if (!reply.sent) { + reply.code(502).send({ error: "Remote proxy request failed" }) + } + } +} + +function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string { + const parsed = new URL(rawUrl, "https://localhost") + const url = new URL(baseUrl.toString()) + url.pathname = rewriteRequestPath(baseUrl, parsed.pathname) + url.search = stripInternalQuery(parsed.search) + url.hash = "" + return url.toString() +} + +function rewriteRequestPath(baseUrl: URL, requestPath: string): string { + const basePath = normalizedBasePath(baseUrl) + if (basePath === "/") { + return requestPath + } + + if (requestPath === "/") { + return basePath + } + + if (pathHasBasePrefix(basePath, requestPath)) { + return requestPath + } + + return `${basePath}${requestPath}` +} + +function normalizedBasePath(baseUrl: URL): string { + return baseUrl.pathname || "/" +} + +function pathHasBasePrefix(basePath: string, requestPath: string): boolean { + return requestPath === basePath || requestPath.startsWith(`${basePath}/`) +} + +function stripInternalQuery(search: string): string { + if (!search || search === "?") { + return "" + } + return search +} + +function filterRequestHeaders( + headers: FastifyRequest["headers"], + session: RemoteProxySession, +): Record { + const next: Record = {} + for (const [key, value] of Object.entries(headers ?? {})) { + if (!value) continue + const lower = key.toLowerCase() + if ( + isHopByHopHeader(lower) || + lower === "host" || + lower === "content-length" || + lower === "accept-encoding" + ) { + continue + } + if (lower === "origin") { + next[key] = session.targetBaseUrl.origin + continue + } + if (lower === "referer") { + const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl) + if (rewritten) { + next[key] = rewritten + } + continue + } + if (lower === "cookie") { + const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix) + if (rewritten) { + next[key] = rewritten + } + continue + } + next[key] = Array.isArray(value) ? value.join(",") : value + } + + next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname + if (!next.origin) { + next.origin = session.targetBaseUrl.origin + } + return next +} + +function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null { + if (!referer) { + return null + } + + try { + const parsed = new URL(referer) + const rewritten = new URL(targetBaseUrl.toString()) + rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname) + rewritten.search = parsed.search + rewritten.hash = parsed.hash + return rewritten.toString() + } catch { + return null + } +} + +function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) { + const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined + if (Array.isArray(setCookie)) { + for (const cookie of setCookie) { + reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix)) + } + } + + response.headers.forEach((value: string, key: string) => { + const lower = key.toLowerCase() + if ( + isHopByHopHeader(lower) || + lower === "set-cookie" || + lower === "content-length" || + lower === "content-encoding" + ) { + return + } + + if (lower === "location") { + reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl)) + return + } + + reply.header(key, value) + }) +} + +function toOutgoingHeaders(headers: ReturnType): Record { + const next: Record = {} + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) { + continue + } + next[key] = Array.isArray(value) ? value.map(String) : String(value) + } + return next +} + +function rewriteSetCookie(cookie: string, cookiePrefix: string): string { + const parts = cookie.split(";").map((part) => part.trim()) + const first = parts.shift() ?? "" + const separator = first.indexOf("=") + if (separator <= 0) { + return cookie + } + + const name = first.slice(0, separator).trim() + const value = first.slice(separator + 1) + const rewritten = [`${cookiePrefix}${name}=${value}`] + for (const part of parts) { + if (part.slice(0, 7).toLowerCase().startsWith("domain=")) { + continue + } + rewritten.push(part) + } + return rewritten.join("; ") +} + +function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string { + const next: string[] = [] + for (const rawPart of cookieHeader.split(";")) { + const part = rawPart.trim() + if (!part) continue + const separator = part.indexOf("=") + if (separator <= 0) continue + const name = part.slice(0, separator).trim() + const value = part.slice(separator + 1) + if (!name.startsWith(cookiePrefix)) { + continue + } + next.push(`${name.slice(cookiePrefix.length)}=${value}`) + } + return next.join("; ") +} + +function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string { + try { + const parsed = new URL(location, targetBaseUrl) + if (parsed.origin !== targetBaseUrl.origin) { + return location + } + + const rewritten = new URL(localBaseUrl.toString()) + rewritten.pathname = parsed.pathname + rewritten.search = parsed.search + rewritten.hash = parsed.hash + return rewritten.toString() + } catch { + return location + } +} + +function isHopByHopHeader(name: string): boolean { + return new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ]).has(name) +} diff --git a/packages/server/src/server/routes/meta.ts b/packages/server/src/server/routes/meta.ts index 4c0ad95b0..69cfd9c48 100644 --- a/packages/server/src/server/routes/meta.ts +++ b/packages/server/src/server/routes/meta.ts @@ -5,15 +5,20 @@ import { ServerMeta } from "../../api-types" interface RouteDeps { serverMeta: ServerMeta } + export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta)) } + function buildMetaResponse(meta: ServerMeta): ServerMeta { const localPort = resolveLocalPort(meta) + const remote = resolveRemote(meta) return { ...meta, localPort, + remotePort: remote?.port, + listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local", } } @@ -29,3 +34,23 @@ function resolveLocalPort(meta: ServerMeta): number { return 0 } } + +function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null { + if (!meta.remoteUrl) { + return null + } + try { + const parsed = new URL(meta.remoteUrl) + const protocol = parsed.protocol === "https:" ? "https" : "http" + const port = Number(parsed.port) + return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 } + } catch { + return null + } +} + +function isLoopbackHost(host: string): boolean { + return host === "127.0.0.1" || host === "::1" || host.startsWith("127.") +} + +// NetworkAddress shape is resolved in ../network-addresses diff --git a/packages/server/src/server/routes/remote-proxy.ts b/packages/server/src/server/routes/remote-proxy.ts new file mode 100644 index 000000000..a26cdc107 --- /dev/null +++ b/packages/server/src/server/routes/remote-proxy.ts @@ -0,0 +1,54 @@ +import type { FastifyInstance } from "fastify" +import { z } from "zod" +import type { RemoteProxySessionCreateResponse } from "../../api-types" +import { isLoopbackAddress } from "../../auth/http-auth" +import type { Logger } from "../../logger" +import type { RemoteProxySessionManager } from "../remote-proxy" + +interface RouteDeps { + logger: Logger + sessionManager: RemoteProxySessionManager +} + +const CreateSessionSchema = z.object({ + baseUrl: z.string().min(1), + skipTlsVerify: z.boolean().optional(), +}) + +const SessionParamsSchema = z.object({ + id: z.string().uuid(), +}) + +export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) { + app.post("/api/remote-proxy/sessions", async (request, reply): Promise => { + try { + const body = CreateSessionSchema.parse(request.body ?? {}) + return await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify)) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to create remote proxy session") + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" } + } + }) + + app.delete("/api/remote-proxy/sessions/:id", async (request, reply): Promise<{ ok: boolean } | { error: string }> => { + if (!isLoopbackAddress(request.socket.remoteAddress)) { + reply.code(404) + return { error: "Not found" } + } + + try { + const params = SessionParamsSchema.parse(request.params ?? {}) + const deleted = await deps.sessionManager.deleteSession(params.id) + if (!deleted) { + reply.code(404) + return { error: "Remote proxy session not found" } + } + return { ok: true } + } catch (error) { + deps.logger.warn({ err: error }, "Failed to delete remote proxy session") + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to delete remote proxy session" } + } + }) +} diff --git a/packages/server/src/server/routes/remote-servers.ts b/packages/server/src/server/routes/remote-servers.ts new file mode 100644 index 000000000..86c005694 --- /dev/null +++ b/packages/server/src/server/routes/remote-servers.ts @@ -0,0 +1,166 @@ +import { Agent, fetch } from "undici" +import type { FastifyInstance } from "fastify" +import { z } from "zod" +import type { Logger } from "../../logger" +import type { RemoteServerProbeResponse } from "../../api-types" + +interface RouteDeps { + logger: Logger +} + +const ProbeSchema = z.object({ + baseUrl: z.string().min(1), + skipTlsVerify: z.boolean().optional(), +}) + +const PROBE_TIMEOUT_MS = 8_000 + +export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) { + app.post("/api/remote-servers/probe", async (request, reply) => { + try { + const body = ProbeSchema.parse(request.body ?? {}) + return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify)) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to probe remote server") + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid request" } + } + }) +} + +async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise { + const normalizedUrl = normalizeBaseUrl(baseUrl) + const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) + const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined + + try { + const response = await fetch(probeUrl, { + method: "GET", + dispatcher, + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }) + + if (!response.ok) { + return { + ok: false, + reachable: true, + normalizedUrl, + skipTlsVerify, + requiresAuth: false, + authenticated: false, + error: `Remote server returned HTTP ${response.status}`, + errorCode: "http_error", + } + } + + const payload = (await response.json()) as { authenticated?: unknown } + if (typeof payload?.authenticated !== "boolean") { + return { + ok: false, + reachable: true, + normalizedUrl, + skipTlsVerify, + requiresAuth: false, + authenticated: false, + error: "Remote server did not return a valid CodeNomad auth response", + errorCode: "invalid_server", + } + } + + return { + ok: true, + reachable: true, + normalizedUrl, + skipTlsVerify, + requiresAuth: !payload.authenticated, + authenticated: payload.authenticated, + } + } catch (error) { + const message = describeProbeError(error) + return { + ok: false, + reachable: false, + normalizedUrl, + skipTlsVerify, + requiresAuth: false, + authenticated: false, + error: message.message, + errorCode: message.code, + } + } finally { + clearTimeout(timeout) + await dispatcher?.close().catch(() => {}) + } +} + +function normalizeBaseUrl(input: string): string { + const parsed = new URL(input.trim()) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Server URL must use http:// or https://") + } + + parsed.hash = "" + parsed.search = "" + parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/" + const value = parsed.toString() + return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "") +} + +function describeProbeError(error: unknown): { code: string; message: string } { + const chain = unwrapErrorChain(error) + const detailed = + chain.find((entry) => { + const code = (entry?.code ?? "").toString() + return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE" + }) ?? chain[0] + + const code = (detailed?.code ?? "").toString() + const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim() + + if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") { + return { + code: "tls_error", + message: "Certificate check failed while connecting to the remote server.", + } + } + + return { + code: + code === "ERR_INVALID_URL" + ? "invalid_url" + : code === "ECONNREFUSED" + ? "connection_refused" + : code === "ENOTFOUND" + ? "dns_error" + : code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR" + ? "timeout" + : code + ? code.toLowerCase() + : "probe_failed", + message: exactMessage || "Failed to connect to the remote server.", + } +} + +function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> { + const results: Array<{ code?: unknown; message?: string }> = [] + let current: unknown = error + const seen = new Set() + + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current) + const entry = current as { code?: unknown; message?: string; cause?: unknown } + results.push({ code: entry.code, message: entry.message }) + current = entry.cause + } + + if (results.length === 0 && error instanceof Error) { + results.push({ message: error.message }) + } + + return results +} diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts index ec9f9a9b7..5220805c2 100644 --- a/packages/server/src/settings/migrate.ts +++ b/packages/server/src/settings/migrate.ts @@ -103,6 +103,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co if (isPlainObject(envVars)) { serverConfig.environmentVariables = { ...envVars } } + const listeningMode = preferences.listeningMode + if (typeof listeningMode === "string") { + serverConfig.listeningMode = listeningMode + } const logLevel = preferences.logLevel if (typeof logLevel === "string") { serverConfig.logLevel = logLevel @@ -134,6 +138,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co // Remaining preferences are treated as stable UI settings. const moved = new Set([ "environmentVariables", + "listeningMode", "logLevel", "lastUsedBinary", "modelRecents", diff --git a/packages/server/src/shutdown.test.ts b/packages/server/src/shutdown.test.ts index 84dc96f52..d96063fdc 100644 --- a/packages/server/src/shutdown.test.ts +++ b/packages/server/src/shutdown.test.ts @@ -11,7 +11,7 @@ import { const logger = { info() {}, warn() {}, error() {} } const operations = (overrides: Partial = {}): ServerShutdownOperations => ({ stopInstanceEventBridge() {}, stopSidecars() {}, stopClientConnections() {}, - stopRemoteControl() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {}, + stopRemoteControl() {}, stopRemoteProxySessions() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {}, ...overrides, }) @@ -21,10 +21,11 @@ describe("server shutdown orchestration", () => { let attempts = 0 await orchestrateServerShutdown(operations({ stopRemoteControl: () => { calls.push("remote-control") }, + stopRemoteProxySessions: () => { calls.push("remote-proxy") }, stopWorkspaces: () => { calls.push(`workspaces-${++attempts}`); if (attempts === 1) throw new Error("still alive") }, stopHttpServers: () => { calls.push("http") }, }), logger) - assert.deepEqual(calls, ["workspaces-1", "remote-control", "workspaces-2", "http"]) + assert.deepEqual(calls, ["workspaces-1", "remote-control", "remote-proxy", "workspaces-2", "http"]) }) it("closes remaining resources and aggregates the concrete current error", async () => { diff --git a/packages/server/src/shutdown.ts b/packages/server/src/shutdown.ts index 7fdac26e3..bdeae2fb2 100644 --- a/packages/server/src/shutdown.ts +++ b/packages/server/src/shutdown.ts @@ -6,7 +6,7 @@ export const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete" export const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete" export type ServerShutdownOperations = Record< - "stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteControl" | "stopWorkspaces" | + "stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteControl" | "stopRemoteProxySessions" | "stopWorkspaces" | "stopHttpServers" | "stopReleaseMonitor", ShutdownOperation > @@ -93,6 +93,7 @@ export async function orchestrateServerShutdown( settle([ ["stopInstanceEventBridge", operations.stopInstanceEventBridge], ["stopSidecars", operations.stopSidecars], ["stopClientConnections", operations.stopClientConnections], ["stopRemoteControl", operations.stopRemoteControl], + ["stopRemoteProxySessions", operations.stopRemoteProxySessions], ]), workspaceShutdown, ]) diff --git a/packages/tauri-app/src-tauri/build.rs b/packages/tauri-app/src-tauri/build.rs index 6697ac9e3..888523d30 100644 --- a/packages/tauri-app/src-tauri/build.rs +++ b/packages/tauri-app/src-tauri/build.rs @@ -40,6 +40,7 @@ fn main() { "cli_restart", "wake_lock_start", "wake_lock_stop", + "needs_local_certificate_install", "open_preferences_window", "preferences_window_ready", "preferences_get_request", @@ -47,6 +48,7 @@ fn main() { "preferences_resolve_transition", "window_control", "popup_titlebar_menu", + "open_remote_window", "client_state_claim_access", "client_state_load", "client_state_save", diff --git a/packages/tauri-app/src-tauri/capabilities/main-window.json b/packages/tauri-app/src-tauri/capabilities/main-window.json index fbf76fcbb..dae3a106b 100644 --- a/packages/tauri-app/src-tauri/capabilities/main-window.json +++ b/packages/tauri-app/src-tauri/capabilities/main-window.json @@ -29,7 +29,9 @@ "allow-cli-restart", "allow-wake-lock-start", "allow-wake-lock-stop", + "allow-needs-local-certificate-install", "allow-open-preferences-window", + "allow-open-remote-window", "allow-client-state-claim-access", "allow-client-state-load", "allow-client-state-save", diff --git a/packages/tauri-app/src-tauri/capabilities/preferences-window.json b/packages/tauri-app/src-tauri/capabilities/preferences-window.json index b7d2fa9b0..24ec40b97 100644 --- a/packages/tauri-app/src-tauri/capabilities/preferences-window.json +++ b/packages/tauri-app/src-tauri/capabilities/preferences-window.json @@ -29,6 +29,8 @@ "notification:allow-notify", "notification:allow-show", "allow-cli-get-status", - "allow-cli-restart" + "allow-cli-restart", + "allow-needs-local-certificate-install", + "allow-open-remote-window" ] } diff --git a/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json new file mode 100644 index 000000000..210c8d88b --- /dev/null +++ b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.tauri.app/capabilities.json", + "identifier": "remote-window-notifications", + "description": "Grant remote CodeNomad windows access only to native OS notifications.", + "local": false, + "remote": { + "urls": ["http://*:*", "https://*:*"] + }, + "windows": ["remote-*"], + "permissions": [ + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify" + ] +} diff --git a/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json b/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json index 13b08943b..79fd3a9c9 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json +++ b/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-cli-get-status":{"identifier":"allow-cli-get-status","description":"Enables the cli_get_status command without any pre-configured scope.","commands":{"allow":["cli_get_status"],"deny":[]}},"allow-cli-restart":{"identifier":"allow-cli-restart","description":"Enables the cli_restart command without any pre-configured scope.","commands":{"allow":["cli_restart"],"deny":[]}},"allow-client-state-claim-access":{"identifier":"allow-client-state-claim-access","description":"Enables the client_state_claim_access command without any pre-configured scope.","commands":{"allow":["client_state_claim_access"],"deny":[]}},"allow-client-state-clear":{"identifier":"allow-client-state-clear","description":"Enables the client_state_clear command without any pre-configured scope.","commands":{"allow":["client_state_clear"],"deny":[]}},"allow-client-state-commit-partitions":{"identifier":"allow-client-state-commit-partitions","description":"Enables the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":["client_state_commit_partitions"],"deny":[]}},"allow-client-state-load":{"identifier":"allow-client-state-load","description":"Enables the client_state_load command without any pre-configured scope.","commands":{"allow":["client_state_load"],"deny":[]}},"allow-client-state-load-partition":{"identifier":"allow-client-state-load-partition","description":"Enables the client_state_load_partition command without any pre-configured scope.","commands":{"allow":["client_state_load_partition"],"deny":[]}},"allow-client-state-navigation-flushed":{"identifier":"allow-client-state-navigation-flushed","description":"Enables the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":["client_state_navigation_flushed"],"deny":[]}},"allow-client-state-renderer-flushed":{"identifier":"allow-client-state-renderer-flushed","description":"Enables the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":["client_state_renderer_flushed"],"deny":[]}},"allow-client-state-save":{"identifier":"allow-client-state-save","description":"Enables the client_state_save command without any pre-configured scope.","commands":{"allow":["client_state_save"],"deny":[]}},"allow-client-state-set-restore-enabled":{"identifier":"allow-client-state-set-restore-enabled","description":"Enables the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":["client_state_set_restore_enabled"],"deny":[]}},"allow-desktop-launch-acknowledge-folder":{"identifier":"allow-desktop-launch-acknowledge-folder","description":"Enables the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_acknowledge_folder"],"deny":[]}},"allow-desktop-launch-next-folder":{"identifier":"allow-desktop-launch-next-folder","description":"Enables the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_next_folder"],"deny":[]}},"allow-desktop-launch-ready":{"identifier":"allow-desktop-launch-ready","description":"Enables the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":["desktop_launch_ready"],"deny":[]}},"allow-developer-mode-get":{"identifier":"allow-developer-mode-get","description":"Enables the developer_mode_get command without any pre-configured scope.","commands":{"allow":["developer_mode_get"],"deny":[]}},"allow-developer-mode-set":{"identifier":"allow-developer-mode-set","description":"Enables the developer_mode_set command without any pre-configured scope.","commands":{"allow":["developer_mode_set"],"deny":[]}},"allow-install-stable-update":{"identifier":"allow-install-stable-update","description":"Enables the install_stable_update command without any pre-configured scope.","commands":{"allow":["install_stable_update"],"deny":[]}},"allow-open-preferences-window":{"identifier":"allow-open-preferences-window","description":"Enables the open_preferences_window command without any pre-configured scope.","commands":{"allow":["open_preferences_window"],"deny":[]}},"allow-open-workspace-target":{"identifier":"allow-open-workspace-target","description":"Enables the open_workspace_target command without any pre-configured scope.","commands":{"allow":["open_workspace_target"],"deny":[]}},"allow-popup-titlebar-menu":{"identifier":"allow-popup-titlebar-menu","description":"Enables the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":["popup_titlebar_menu"],"deny":[]}},"allow-preferences-accept-request":{"identifier":"allow-preferences-accept-request","description":"Enables the preferences_accept_request command without any pre-configured scope.","commands":{"allow":["preferences_accept_request"],"deny":[]}},"allow-preferences-get-request":{"identifier":"allow-preferences-get-request","description":"Enables the preferences_get_request command without any pre-configured scope.","commands":{"allow":["preferences_get_request"],"deny":[]}},"allow-preferences-resolve-transition":{"identifier":"allow-preferences-resolve-transition","description":"Enables the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":["preferences_resolve_transition"],"deny":[]}},"allow-preferences-window-ready":{"identifier":"allow-preferences-window-ready","description":"Enables the preferences_window_ready command without any pre-configured scope.","commands":{"allow":["preferences_window_ready"],"deny":[]}},"allow-set-workspace-menu-enabled":{"identifier":"allow-set-workspace-menu-enabled","description":"Enables the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":["set_workspace_menu_enabled"],"deny":[]}},"allow-wake-lock-start":{"identifier":"allow-wake-lock-start","description":"Enables the wake_lock_start command without any pre-configured scope.","commands":{"allow":["wake_lock_start"],"deny":[]}},"allow-wake-lock-stop":{"identifier":"allow-wake-lock-stop","description":"Enables the wake_lock_stop command without any pre-configured scope.","commands":{"allow":["wake_lock_stop"],"deny":[]}},"allow-window-control":{"identifier":"allow-window-control","description":"Enables the window_control command without any pre-configured scope.","commands":{"allow":["window_control"],"deny":[]}},"deny-cli-get-status":{"identifier":"deny-cli-get-status","description":"Denies the cli_get_status command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_get_status"]}},"deny-cli-restart":{"identifier":"deny-cli-restart","description":"Denies the cli_restart command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_restart"]}},"deny-client-state-claim-access":{"identifier":"deny-client-state-claim-access","description":"Denies the client_state_claim_access command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_claim_access"]}},"deny-client-state-clear":{"identifier":"deny-client-state-clear","description":"Denies the client_state_clear command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_clear"]}},"deny-client-state-commit-partitions":{"identifier":"deny-client-state-commit-partitions","description":"Denies the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_commit_partitions"]}},"deny-client-state-load":{"identifier":"deny-client-state-load","description":"Denies the client_state_load command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load"]}},"deny-client-state-load-partition":{"identifier":"deny-client-state-load-partition","description":"Denies the client_state_load_partition command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load_partition"]}},"deny-client-state-navigation-flushed":{"identifier":"deny-client-state-navigation-flushed","description":"Denies the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_navigation_flushed"]}},"deny-client-state-renderer-flushed":{"identifier":"deny-client-state-renderer-flushed","description":"Denies the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_renderer_flushed"]}},"deny-client-state-save":{"identifier":"deny-client-state-save","description":"Denies the client_state_save command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_save"]}},"deny-client-state-set-restore-enabled":{"identifier":"deny-client-state-set-restore-enabled","description":"Denies the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_set_restore_enabled"]}},"deny-desktop-launch-acknowledge-folder":{"identifier":"deny-desktop-launch-acknowledge-folder","description":"Denies the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_acknowledge_folder"]}},"deny-desktop-launch-next-folder":{"identifier":"deny-desktop-launch-next-folder","description":"Denies the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_next_folder"]}},"deny-desktop-launch-ready":{"identifier":"deny-desktop-launch-ready","description":"Denies the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_ready"]}},"deny-developer-mode-get":{"identifier":"deny-developer-mode-get","description":"Denies the developer_mode_get command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_mode_get"]}},"deny-developer-mode-set":{"identifier":"deny-developer-mode-set","description":"Denies the developer_mode_set command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_mode_set"]}},"deny-install-stable-update":{"identifier":"deny-install-stable-update","description":"Denies the install_stable_update command without any pre-configured scope.","commands":{"allow":[],"deny":["install_stable_update"]}},"deny-open-preferences-window":{"identifier":"deny-open-preferences-window","description":"Denies the open_preferences_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_preferences_window"]}},"deny-open-workspace-target":{"identifier":"deny-open-workspace-target","description":"Denies the open_workspace_target command without any pre-configured scope.","commands":{"allow":[],"deny":["open_workspace_target"]}},"deny-popup-titlebar-menu":{"identifier":"deny-popup-titlebar-menu","description":"Denies the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["popup_titlebar_menu"]}},"deny-preferences-accept-request":{"identifier":"deny-preferences-accept-request","description":"Denies the preferences_accept_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_accept_request"]}},"deny-preferences-get-request":{"identifier":"deny-preferences-get-request","description":"Denies the preferences_get_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_get_request"]}},"deny-preferences-resolve-transition":{"identifier":"deny-preferences-resolve-transition","description":"Denies the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_resolve_transition"]}},"deny-preferences-window-ready":{"identifier":"deny-preferences-window-ready","description":"Denies the preferences_window_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_window_ready"]}},"deny-set-workspace-menu-enabled":{"identifier":"deny-set-workspace-menu-enabled","description":"Denies the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_workspace_menu_enabled"]}},"deny-wake-lock-start":{"identifier":"deny-wake-lock-start","description":"Denies the wake_lock_start command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_start"]}},"deny-wake-lock-stop":{"identifier":"deny-wake-lock-stop","description":"Denies the wake_lock_stop command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_stop"]}},"deny-window-control":{"identifier":"deny-window-control","description":"Denies the window_control command without any pre-configured scope.","commands":{"allow":[],"deny":["window_control"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-cli-get-status":{"identifier":"allow-cli-get-status","description":"Enables the cli_get_status command without any pre-configured scope.","commands":{"allow":["cli_get_status"],"deny":[]}},"allow-cli-restart":{"identifier":"allow-cli-restart","description":"Enables the cli_restart command without any pre-configured scope.","commands":{"allow":["cli_restart"],"deny":[]}},"allow-client-state-claim-access":{"identifier":"allow-client-state-claim-access","description":"Enables the client_state_claim_access command without any pre-configured scope.","commands":{"allow":["client_state_claim_access"],"deny":[]}},"allow-client-state-clear":{"identifier":"allow-client-state-clear","description":"Enables the client_state_clear command without any pre-configured scope.","commands":{"allow":["client_state_clear"],"deny":[]}},"allow-client-state-commit-partitions":{"identifier":"allow-client-state-commit-partitions","description":"Enables the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":["client_state_commit_partitions"],"deny":[]}},"allow-client-state-load":{"identifier":"allow-client-state-load","description":"Enables the client_state_load command without any pre-configured scope.","commands":{"allow":["client_state_load"],"deny":[]}},"allow-client-state-load-partition":{"identifier":"allow-client-state-load-partition","description":"Enables the client_state_load_partition command without any pre-configured scope.","commands":{"allow":["client_state_load_partition"],"deny":[]}},"allow-client-state-navigation-flushed":{"identifier":"allow-client-state-navigation-flushed","description":"Enables the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":["client_state_navigation_flushed"],"deny":[]}},"allow-client-state-renderer-flushed":{"identifier":"allow-client-state-renderer-flushed","description":"Enables the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":["client_state_renderer_flushed"],"deny":[]}},"allow-client-state-save":{"identifier":"allow-client-state-save","description":"Enables the client_state_save command without any pre-configured scope.","commands":{"allow":["client_state_save"],"deny":[]}},"allow-client-state-set-restore-enabled":{"identifier":"allow-client-state-set-restore-enabled","description":"Enables the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":["client_state_set_restore_enabled"],"deny":[]}},"allow-desktop-launch-acknowledge-folder":{"identifier":"allow-desktop-launch-acknowledge-folder","description":"Enables the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_acknowledge_folder"],"deny":[]}},"allow-desktop-launch-next-folder":{"identifier":"allow-desktop-launch-next-folder","description":"Enables the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_next_folder"],"deny":[]}},"allow-desktop-launch-ready":{"identifier":"allow-desktop-launch-ready","description":"Enables the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":["desktop_launch_ready"],"deny":[]}},"allow-developer-mode-get":{"identifier":"allow-developer-mode-get","description":"Enables the developer_mode_get command without any pre-configured scope.","commands":{"allow":["developer_mode_get"],"deny":[]}},"allow-developer-mode-set":{"identifier":"allow-developer-mode-set","description":"Enables the developer_mode_set command without any pre-configured scope.","commands":{"allow":["developer_mode_set"],"deny":[]}},"allow-install-stable-update":{"identifier":"allow-install-stable-update","description":"Enables the install_stable_update command without any pre-configured scope.","commands":{"allow":["install_stable_update"],"deny":[]}},"allow-needs-local-certificate-install":{"identifier":"allow-needs-local-certificate-install","description":"Enables the needs_local_certificate_install command without any pre-configured scope.","commands":{"allow":["needs_local_certificate_install"],"deny":[]}},"allow-open-preferences-window":{"identifier":"allow-open-preferences-window","description":"Enables the open_preferences_window command without any pre-configured scope.","commands":{"allow":["open_preferences_window"],"deny":[]}},"allow-open-remote-window":{"identifier":"allow-open-remote-window","description":"Enables the open_remote_window command without any pre-configured scope.","commands":{"allow":["open_remote_window"],"deny":[]}},"allow-open-workspace-target":{"identifier":"allow-open-workspace-target","description":"Enables the open_workspace_target command without any pre-configured scope.","commands":{"allow":["open_workspace_target"],"deny":[]}},"allow-popup-titlebar-menu":{"identifier":"allow-popup-titlebar-menu","description":"Enables the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":["popup_titlebar_menu"],"deny":[]}},"allow-preferences-accept-request":{"identifier":"allow-preferences-accept-request","description":"Enables the preferences_accept_request command without any pre-configured scope.","commands":{"allow":["preferences_accept_request"],"deny":[]}},"allow-preferences-get-request":{"identifier":"allow-preferences-get-request","description":"Enables the preferences_get_request command without any pre-configured scope.","commands":{"allow":["preferences_get_request"],"deny":[]}},"allow-preferences-resolve-transition":{"identifier":"allow-preferences-resolve-transition","description":"Enables the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":["preferences_resolve_transition"],"deny":[]}},"allow-preferences-window-ready":{"identifier":"allow-preferences-window-ready","description":"Enables the preferences_window_ready command without any pre-configured scope.","commands":{"allow":["preferences_window_ready"],"deny":[]}},"allow-set-workspace-menu-enabled":{"identifier":"allow-set-workspace-menu-enabled","description":"Enables the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":["set_workspace_menu_enabled"],"deny":[]}},"allow-wake-lock-start":{"identifier":"allow-wake-lock-start","description":"Enables the wake_lock_start command without any pre-configured scope.","commands":{"allow":["wake_lock_start"],"deny":[]}},"allow-wake-lock-stop":{"identifier":"allow-wake-lock-stop","description":"Enables the wake_lock_stop command without any pre-configured scope.","commands":{"allow":["wake_lock_stop"],"deny":[]}},"allow-window-control":{"identifier":"allow-window-control","description":"Enables the window_control command without any pre-configured scope.","commands":{"allow":["window_control"],"deny":[]}},"deny-cli-get-status":{"identifier":"deny-cli-get-status","description":"Denies the cli_get_status command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_get_status"]}},"deny-cli-restart":{"identifier":"deny-cli-restart","description":"Denies the cli_restart command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_restart"]}},"deny-client-state-claim-access":{"identifier":"deny-client-state-claim-access","description":"Denies the client_state_claim_access command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_claim_access"]}},"deny-client-state-clear":{"identifier":"deny-client-state-clear","description":"Denies the client_state_clear command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_clear"]}},"deny-client-state-commit-partitions":{"identifier":"deny-client-state-commit-partitions","description":"Denies the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_commit_partitions"]}},"deny-client-state-load":{"identifier":"deny-client-state-load","description":"Denies the client_state_load command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load"]}},"deny-client-state-load-partition":{"identifier":"deny-client-state-load-partition","description":"Denies the client_state_load_partition command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load_partition"]}},"deny-client-state-navigation-flushed":{"identifier":"deny-client-state-navigation-flushed","description":"Denies the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_navigation_flushed"]}},"deny-client-state-renderer-flushed":{"identifier":"deny-client-state-renderer-flushed","description":"Denies the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_renderer_flushed"]}},"deny-client-state-save":{"identifier":"deny-client-state-save","description":"Denies the client_state_save command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_save"]}},"deny-client-state-set-restore-enabled":{"identifier":"deny-client-state-set-restore-enabled","description":"Denies the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_set_restore_enabled"]}},"deny-desktop-launch-acknowledge-folder":{"identifier":"deny-desktop-launch-acknowledge-folder","description":"Denies the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_acknowledge_folder"]}},"deny-desktop-launch-next-folder":{"identifier":"deny-desktop-launch-next-folder","description":"Denies the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_next_folder"]}},"deny-desktop-launch-ready":{"identifier":"deny-desktop-launch-ready","description":"Denies the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_ready"]}},"deny-developer-mode-get":{"identifier":"deny-developer-mode-get","description":"Denies the developer_mode_get command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_mode_get"]}},"deny-developer-mode-set":{"identifier":"deny-developer-mode-set","description":"Denies the developer_mode_set command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_mode_set"]}},"deny-install-stable-update":{"identifier":"deny-install-stable-update","description":"Denies the install_stable_update command without any pre-configured scope.","commands":{"allow":[],"deny":["install_stable_update"]}},"deny-needs-local-certificate-install":{"identifier":"deny-needs-local-certificate-install","description":"Denies the needs_local_certificate_install command without any pre-configured scope.","commands":{"allow":[],"deny":["needs_local_certificate_install"]}},"deny-open-preferences-window":{"identifier":"deny-open-preferences-window","description":"Denies the open_preferences_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_preferences_window"]}},"deny-open-remote-window":{"identifier":"deny-open-remote-window","description":"Denies the open_remote_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_remote_window"]}},"deny-open-workspace-target":{"identifier":"deny-open-workspace-target","description":"Denies the open_workspace_target command without any pre-configured scope.","commands":{"allow":[],"deny":["open_workspace_target"]}},"deny-popup-titlebar-menu":{"identifier":"deny-popup-titlebar-menu","description":"Denies the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["popup_titlebar_menu"]}},"deny-preferences-accept-request":{"identifier":"deny-preferences-accept-request","description":"Denies the preferences_accept_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_accept_request"]}},"deny-preferences-get-request":{"identifier":"deny-preferences-get-request","description":"Denies the preferences_get_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_get_request"]}},"deny-preferences-resolve-transition":{"identifier":"deny-preferences-resolve-transition","description":"Denies the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_resolve_transition"]}},"deny-preferences-window-ready":{"identifier":"deny-preferences-window-ready","description":"Denies the preferences_window_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_window_ready"]}},"deny-set-workspace-menu-enabled":{"identifier":"deny-set-workspace-menu-enabled","description":"Denies the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_workspace_menu_enabled"]}},"deny-wake-lock-start":{"identifier":"deny-wake-lock-start","description":"Denies the wake_lock_start command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_start"]}},"deny-wake-lock-stop":{"identifier":"deny-wake-lock-stop","description":"Denies the wake_lock_stop command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_stop"]}},"deny-window-control":{"identifier":"deny-window-control","description":"Denies the window_control command without any pre-configured scope.","commands":{"allow":[],"deny":["window_control"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json index 8a9d847e5..83f286aaf 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json +++ b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant local windows access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:1420","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["local-*"],"permissions":["core:default","core:menu:default","allow-window-control","allow-popup-titlebar-menu","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom","allow-cli-get-status","allow-cli-restart","allow-wake-lock-start","allow-wake-lock-stop","allow-open-preferences-window","allow-client-state-claim-access","allow-client-state-load","allow-client-state-save","allow-client-state-commit-partitions","allow-client-state-load-partition","allow-client-state-set-restore-enabled","allow-client-state-clear","allow-client-state-renderer-flushed","allow-client-state-navigation-flushed","allow-desktop-launch-ready","allow-desktop-launch-next-folder","allow-desktop-launch-acknowledge-folder","allow-install-stable-update","allow-open-workspace-target","allow-set-workspace-menu-enabled","allow-developer-mode-get","allow-developer-mode-set"]},"preferences-window":{"identifier":"preferences-window","description":"Grant the singleton Preferences window only settings and HTML window-chrome capabilities.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","https://127.0.0.1:*","https://localhost:*"]},"local":true,"windows":["preferences"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-preferences-window-ready","allow-preferences-get-request","allow-preferences-accept-request","allow-preferences-resolve-transition","allow-window-control","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","allow-cli-get-status","allow-cli-restart"]}} \ No newline at end of file +{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant local windows access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:1420","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["local-*"],"permissions":["core:default","core:menu:default","allow-window-control","allow-popup-titlebar-menu","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom","allow-cli-get-status","allow-cli-restart","allow-wake-lock-start","allow-wake-lock-stop","allow-needs-local-certificate-install","allow-open-preferences-window","allow-open-remote-window","allow-client-state-claim-access","allow-client-state-load","allow-client-state-save","allow-client-state-commit-partitions","allow-client-state-load-partition","allow-client-state-set-restore-enabled","allow-client-state-clear","allow-client-state-renderer-flushed","allow-client-state-navigation-flushed","allow-desktop-launch-ready","allow-desktop-launch-next-folder","allow-desktop-launch-acknowledge-folder","allow-install-stable-update","allow-open-workspace-target","allow-set-workspace-menu-enabled","allow-developer-mode-get","allow-developer-mode-set"]},"preferences-window":{"identifier":"preferences-window","description":"Grant the singleton Preferences window only settings and HTML window-chrome capabilities.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","https://127.0.0.1:*","https://localhost:*"]},"local":true,"windows":["preferences"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-preferences-window-ready","allow-preferences-get-request","allow-preferences-accept-request","allow-preferences-resolve-transition","allow-window-control","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","allow-cli-get-status","allow-cli-restart","allow-needs-local-certificate-install","allow-open-remote-window"]},"remote-window-notifications":{"identifier":"remote-window-notifications","description":"Grant remote CodeNomad windows access only to native OS notifications.","remote":{"urls":["http://*:*","https://*:*"]},"local":false,"windows":["remote-*"],"permissions":["notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json b/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json index cbcb73dbc..21dc3c2d2 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json @@ -446,12 +446,24 @@ "const": "allow-install-stable-update", "markdownDescription": "Enables the install_stable_update command without any pre-configured scope." }, + { + "description": "Enables the needs_local_certificate_install command without any pre-configured scope.", + "type": "string", + "const": "allow-needs-local-certificate-install", + "markdownDescription": "Enables the needs_local_certificate_install command without any pre-configured scope." + }, { "description": "Enables the open_preferences_window command without any pre-configured scope.", "type": "string", "const": "allow-open-preferences-window", "markdownDescription": "Enables the open_preferences_window command without any pre-configured scope." }, + { + "description": "Enables the open_remote_window command without any pre-configured scope.", + "type": "string", + "const": "allow-open-remote-window", + "markdownDescription": "Enables the open_remote_window command without any pre-configured scope." + }, { "description": "Enables the open_workspace_target command without any pre-configured scope.", "type": "string", @@ -614,12 +626,24 @@ "const": "deny-install-stable-update", "markdownDescription": "Denies the install_stable_update command without any pre-configured scope." }, + { + "description": "Denies the needs_local_certificate_install command without any pre-configured scope.", + "type": "string", + "const": "deny-needs-local-certificate-install", + "markdownDescription": "Denies the needs_local_certificate_install command without any pre-configured scope." + }, { "description": "Denies the open_preferences_window command without any pre-configured scope.", "type": "string", "const": "deny-open-preferences-window", "markdownDescription": "Denies the open_preferences_window command without any pre-configured scope." }, + { + "description": "Denies the open_remote_window command without any pre-configured scope.", + "type": "string", + "const": "deny-open-remote-window", + "markdownDescription": "Denies the open_remote_window command without any pre-configured scope." + }, { "description": "Denies the open_workspace_target command without any pre-configured scope.", "type": "string", diff --git a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json index cbcb73dbc..21dc3c2d2 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json @@ -446,12 +446,24 @@ "const": "allow-install-stable-update", "markdownDescription": "Enables the install_stable_update command without any pre-configured scope." }, + { + "description": "Enables the needs_local_certificate_install command without any pre-configured scope.", + "type": "string", + "const": "allow-needs-local-certificate-install", + "markdownDescription": "Enables the needs_local_certificate_install command without any pre-configured scope." + }, { "description": "Enables the open_preferences_window command without any pre-configured scope.", "type": "string", "const": "allow-open-preferences-window", "markdownDescription": "Enables the open_preferences_window command without any pre-configured scope." }, + { + "description": "Enables the open_remote_window command without any pre-configured scope.", + "type": "string", + "const": "allow-open-remote-window", + "markdownDescription": "Enables the open_remote_window command without any pre-configured scope." + }, { "description": "Enables the open_workspace_target command without any pre-configured scope.", "type": "string", @@ -614,12 +626,24 @@ "const": "deny-install-stable-update", "markdownDescription": "Denies the install_stable_update command without any pre-configured scope." }, + { + "description": "Denies the needs_local_certificate_install command without any pre-configured scope.", + "type": "string", + "const": "deny-needs-local-certificate-install", + "markdownDescription": "Denies the needs_local_certificate_install command without any pre-configured scope." + }, { "description": "Denies the open_preferences_window command without any pre-configured scope.", "type": "string", "const": "deny-open-preferences-window", "markdownDescription": "Denies the open_preferences_window command without any pre-configured scope." }, + { + "description": "Denies the open_remote_window command without any pre-configured scope.", + "type": "string", + "const": "deny-open-remote-window", + "markdownDescription": "Denies the open_remote_window command without any pre-configured scope." + }, { "description": "Denies the open_workspace_target command without any pre-configured scope.", "type": "string", diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml new file mode 100644 index 000000000..8870800b9 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-needs-local-certificate-install" +description = "Enables the needs_local_certificate_install command without any pre-configured scope." +commands.allow = ["needs_local_certificate_install"] + +[[permission]] +identifier = "deny-needs-local-certificate-install" +description = "Denies the needs_local_certificate_install command without any pre-configured scope." +commands.deny = ["needs_local_certificate_install"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml new file mode 100644 index 000000000..1c8c6e886 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-remote-window" +description = "Enables the open_remote_window command without any pre-configured scope." +commands.allow = ["open_remote_window"] + +[[permission]] +identifier = "deny-open-remote-window" +description = "Denies the open_remote_window command without any pre-configured scope." +commands.deny = ["open_remote_window"] diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 2ab1dfc62..a575b1bdc 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1,12 +1,15 @@ use crate::managed_node::resolve_bundled_node_binary; +use dirs::home_dir; use parking_lot::Mutex; use regex::Regex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::VecDeque; +use std::env; #[cfg(windows)] use std::ffi::c_void; use std::ffi::OsStr; +use std::fs; use std::io::{BufRead, BufReader, Read, Write}; #[cfg(windows)] use std::mem::{size_of, zeroed}; @@ -635,6 +638,126 @@ fn generate_auth_cookie_name() -> String { format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}") } +const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; + +#[derive(Debug, Deserialize)] +struct PreferencesConfig { + #[serde(rename = "listeningMode")] + listening_mode: Option, +} + +#[derive(Debug, Deserialize)] +struct ServerConfig { + #[serde(rename = "listeningMode")] + listening_mode: Option, +} + +#[derive(Debug, Deserialize)] +struct AppConfig { + preferences: Option, + server: Option, +} + +fn resolve_config_locations() -> (PathBuf, PathBuf) { + let raw = env::var("CLI_CONFIG") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); + + let expanded = expand_home(&raw); + let lower = raw.trim().to_lowercase(); + + if lower.ends_with(".yaml") || lower.ends_with(".yml") { + let base = expanded + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| expanded.clone()); + return (expanded, base.join("config.json")); + } + + if lower.ends_with(".json") { + let base = expanded + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| expanded.clone()); + return (base.join("config.yaml"), expanded); + } + + // Treat as directory. + (expanded.join("config.yaml"), expanded.join("config.json")) +} + +fn expand_home(path: &str) -> PathBuf { + if path.starts_with("~/") { + if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) { + return home.join(path.trim_start_matches("~/")); + } + } + PathBuf::from(path) +} + +fn resolve_listening_mode() -> String { + let (yaml_path, json_path) = resolve_config_locations(); + + if let Ok(content) = fs::read_to_string(&yaml_path) { + if let Ok(config) = serde_yaml::from_str::(&content) { + let mode = config + .server + .as_ref() + .and_then(|srv| srv.listening_mode.as_ref()) + .or_else(|| { + config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + }); + + if let Some(mode) = mode { + if mode == "local" { + return "local".to_string(); + } + if mode == "all" { + return "all".to_string(); + } + } + } + } + + // Legacy fallback. + if let Ok(content) = fs::read_to_string(&json_path) { + if let Ok(config) = serde_json::from_str::(&content) { + let mode = config + .server + .as_ref() + .and_then(|srv| srv.listening_mode.as_ref()) + .or_else(|| { + config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + }); + if let Some(mode) = mode { + if mode == "local" { + return "local".to_string(); + } + if mode == "all" { + return "all".to_string(); + } + } + } + } + "local".to_string() +} + +fn resolve_listening_host() -> String { + let mode = resolve_listening_mode(); + if mode == "local" { + "127.0.0.1".to_string() + } else { + "0.0.0.0".to_string() + } +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum CliState { @@ -978,12 +1101,13 @@ impl CliProcessManager { }; log_line("resolving CLI entry"); let resolution = CliEntry::resolve(&app, dev)?; + let host = resolve_listening_host(); log_line(&format!( - "resolved CLI entry runner={:?} entry={} host=127.0.0.1", - resolution.runner, resolution.entry + "resolved CLI entry runner={:?} entry={} host={}", + resolution.runner, resolution.entry, host )); let auth_cookie_name = Arc::new(generate_auth_cookie_name()); - let args = resolution.build_args(dev, auth_cookie_name.as_str()); + let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { log_line("development mode: will prefer tsx + source if present"); @@ -1569,9 +1693,11 @@ impl CliEntry { )) } - fn build_args(&self, dev: bool, auth_cookie_name: &str) -> Vec { + fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec { let mut args = vec![ "serve".to_string(), + "--host".to_string(), + host.to_string(), "--auth-cookie-name".to_string(), auth_cookie_name.to_string(), "--generate-token".to_string(), @@ -1579,7 +1705,8 @@ impl CliEntry { ]; if dev { - // Dev: keep loopback HTTP for the Vite proxy. + // Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so + // remote proxy sessions can still spin up secure local windows. let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL") .ok() .filter(|value| !value.trim().is_empty()) diff --git a/packages/tauri-app/src-tauri/src/linux_tls.rs b/packages/tauri-app/src-tauri/src/linux_tls.rs new file mode 100644 index 000000000..057020e36 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/linux_tls.rs @@ -0,0 +1,104 @@ +use crate::{clear_remote_tls_handler, AppState}; +use tauri::{AppHandle, Manager, WebviewWindow}; +use url::Url; +use webkit2gtk::{WebContextExt, WebView, WebViewExt}; + +pub fn should_bootstrap_tls_navigation(target_url: &Url, allow_tls_certificate: bool) -> bool { + allow_tls_certificate && target_url.scheme() == "https" +} + +pub fn ensure_remote_window_tls_handler( + window: &WebviewWindow, + app_handle: &AppHandle, + window_label: &str, + window_generation: u64, +) -> Result<(), String> { + { + let state = app_handle.state::(); + let mut handlers = state + .remote_tls_handlers + .lock() + .map_err(|err| err.to_string())?; + if handlers.get(window_label).copied() == Some(window_generation) { + return Ok(()); + } + handlers.insert(window_label.to_string(), window_generation); + } + + let handler_app = app_handle.clone(); + let handler_label = window_label.to_string(); + window + .with_webview(move |platform_webview| { + let webview = platform_webview.inner(); + let app_handle = handler_app.clone(); + let window_label = handler_label.clone(); + webview.connect_load_failed_with_tls_errors( + move |view, failing_uri, certificate, _| { + allow_remote_tls_certificate( + &app_handle, + &window_label, + window_generation, + view, + failing_uri, + certificate, + ) + }, + ); + }) + .map_err(|err| { + if let Ok(mut handlers) = app_handle.state::().remote_tls_handlers.lock() { + clear_remote_tls_handler(&mut handlers, &window_label, window_generation); + } + err.to_string() + }) +} + +fn allow_remote_tls_certificate( + app_handle: &AppHandle, + window_label: &str, + window_generation: u64, + view: &WebView, + failing_uri: &str, + certificate: &webkit2gtk::gio::TlsCertificate, +) -> bool { + let Ok(parsed_uri) = Url::parse(failing_uri) else { + return false; + }; + let Some(host) = parsed_uri.host_str() else { + return false; + }; + + let state = app_handle.state::(); + if state.remote_tls_handlers.lock().map_or(true, |handlers| { + handlers.get(window_label).copied() != Some(window_generation) + }) { + return false; + } + let metadata = state + .remote_navigation + .lock() + .ok() + .and_then(|values| values.get(window_label).cloned()); + let Some(metadata) = metadata else { + return false; + }; + if !metadata.allow_linux_tls_certificate { + return false; + } + if metadata.window_generation != window_generation { + return false; + } + + let parsed_origin = parsed_uri.origin().ascii_serialization(); + if metadata.origin != parsed_origin { + return false; + } + + let Some(context) = view.context() else { + return false; + }; + + context.allow_tls_certificate_for_host(certificate, host); + view.load_uri(failing_uri); + true +} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 36a17ca9a..9507553e0 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -7,6 +7,8 @@ mod client_state; mod developer_mode; mod identity; mod launch; +#[cfg(target_os = "linux")] +mod linux_tls; mod local_windows; mod managed_node; mod native_request; @@ -20,19 +22,22 @@ use keepawake::KeepAwake; use serde::Deserialize; use serde_json::json; use sha2::{Digest, Sha256}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; #[cfg(any(windows, test))] use std::future::Future; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tauri::async_runtime::Mutex as AsyncMutex; use tauri::menu::{ AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder, }; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::webview::{PageLoadEvent, Webview}; -use tauri::{AppHandle, Emitter, Manager, Runtime, Wry}; +use tauri::{ + AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, +}; use tauri_plugin_global_shortcut::{ Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState, }; @@ -49,12 +54,23 @@ use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; const ZOOM_STEP: f64 = 0.1; +const REMOTE_PROXY_CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); const RELEASES_URL: &str = "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"; +const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = + "window.__CODENOMAD_RUNTIME_HOST__ = 'tauri'; window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';"; pub struct AppState { pub manager: CliProcessManager, pub(crate) developer_mode: developer_mode::DeveloperMode, pub wake_lock: Mutex, + remote_navigation: Mutex>, + remote_navigation_generation: AtomicU64, + remote_profiles: Mutex>, + remote_window_operations: RemoteWindowOperationLocks, + remote_proxy_cleanup_claims: Mutex>, + approved_auxiliary_closes: Mutex>, + pub remote_tls_handlers: Mutex>, + pub remote_zoom_levels: Mutex>, pub workspace_menu_items: Mutex>, pub webview_data_directory: std::path::PathBuf, pub developer_browser_arguments: Option, @@ -62,6 +78,66 @@ pub struct AppState { keep_alive_for_remote_control: AtomicBool, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct RemoteWindowMetadata { + origin: String, + title: String, + allow_linux_tls_certificate: bool, + generation: u64, + window_generation: u64, +} + +struct StagedRemoteWindowMetadata { + generation: u64, + window_generation: u64, + previous: Option, +} + +#[derive(Default)] +struct RemoteWindowOperationLocks { + values: Mutex>>>, +} + +impl RemoteWindowOperationLocks { + fn for_label(&self, label: &str) -> Result>, String> { + Ok(self + .values + .lock() + .map_err(|err| err.to_string())? + .entry(label.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum RemoteProfileIdentity { + Direct, + Proxy(String), +} + +impl RemoteProfileIdentity { + fn new(proxy_session_id: Option<&str>) -> Self { + proxy_session_id + .map(|value| Self::Proxy(value.to_string())) + .unwrap_or(Self::Direct) + } + + fn proxy_session_id(&self) -> Option<&str> { + match self { + Self::Direct => None, + Self::Proxy(value) => Some(value), + } + } +} + +fn should_recreate_remote_window( + existing: Option<&RemoteProfileIdentity>, + requested: &RemoteProfileIdentity, +) -> bool { + existing != Some(requested) +} + #[derive(Default)] pub struct WakeLockState { labels: HashSet, @@ -201,6 +277,51 @@ fn set_workspace_menu_enabled( Ok(()) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteWindowPayload { + id: String, + name: String, + base_url: String, + entry_url: Option, + proxy_session_id: Option, + #[allow(dead_code)] + skip_tls_verify: bool, +} + +fn require_http_url(value: &str, name: &str) -> Result { + let url = Url::parse(value).map_err(|error| error.to_string())?; + if !matches!(url.scheme(), "http" | "https") { + return Err(format!("{name} must use HTTP or HTTPS")); + } + Ok(url) +} + +fn claim_unowned_remote_proxy_session( + profiles: &HashMap, + claims: &mut HashSet, + session_id: &str, +) -> bool { + if profiles + .values() + .any(|profile| profile.proxy_session_id() == Some(session_id)) + { + return false; + } + claims.insert(session_id.to_string()) +} + +fn claim_remote_proxy_session_cleanup(app: &AppHandle, session_id: &str) -> bool { + let state = app.state::(); + let Ok(profiles) = state.remote_profiles.lock() else { + return false; + }; + let Ok(mut claims) = state.remote_proxy_cleanup_claims.lock() else { + return false; + }; + claim_unowned_remote_proxy_session(&profiles, &mut claims, session_id) +} + #[tauri::command] fn developer_mode_get( window: tauri::WebviewWindow, @@ -291,6 +412,101 @@ async fn popup_titlebar_menu( .map_err(|error| error.to_string()) } +fn release_remote_proxy_session_cleanup(app: &AppHandle, session_id: &str) { + if let Ok(mut claims) = app.state::().remote_proxy_cleanup_claims.lock() { + claims.remove(session_id); + } +} + +async fn cleanup_remote_proxy_session_if_unowned(app: &AppHandle, session_id: &str) { + if !claim_remote_proxy_session_cleanup(app, session_id) { + return; + } + if let Err(err) = cleanup_remote_proxy_session(app, session_id).await { + release_remote_proxy_session_cleanup(app, session_id); + eprintln!( + "[tauri] failed to clean up remote proxy session {}: {}", + session_id, err + ); + } +} + +fn schedule_remote_proxy_session_cleanup(app: AppHandle, label: String, session_id: String) { + tauri::async_runtime::spawn(async move { + let Ok(operation) = app + .state::() + .remote_window_operations + .for_label(&label) + else { + return; + }; + let _guard = operation.lock().await; + cleanup_remote_proxy_session_if_unowned(&app, &session_id).await; + }); +} + +fn schedule_remote_window_destroyed_cleanup( + app: AppHandle, + label: String, + profile: RemoteProfileIdentity, + window_generation: u64, +) { + tauri::async_runtime::spawn(async move { + let Ok(operation) = app + .state::() + .remote_window_operations + .for_label(&label) + else { + return; + }; + let _guard = operation.lock().await; + clear_remote_window_metadata(&app, &label, &profile, window_generation); + if let Some(session_id) = profile.proxy_session_id() { + cleanup_remote_proxy_session_if_unowned(&app, session_id).await; + } + }); +} + +async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> { + let status = app.state::().manager.status(); + let Some(base_url) = status.url else { + return Err("backend is unavailable".to_string()); + }; + + let mut cleanup_url = Url::parse(&base_url).map_err(|err| err.to_string())?; + cleanup_url.set_path(&format!("/api/remote-proxy/sessions/{session_id}")); + cleanup_url.set_query(None); + cleanup_url.set_fragment(None); + + let client = if cleanup_url.scheme() == "https" { + let local_cert = cert_manager::ensure_local_cert()?; + let ca_cert = reqwest::Certificate::from_der(&local_cert.ca_cert_der) + .map_err(|err| err.to_string())?; + reqwest::Client::builder() + .add_root_certificate(ca_cert) + .timeout(REMOTE_PROXY_CLEANUP_TIMEOUT) + .build() + .map_err(|err| err.to_string())? + } else { + reqwest::Client::builder() + .timeout(REMOTE_PROXY_CLEANUP_TIMEOUT) + .build() + .map_err(|err| err.to_string())? + }; + + let response = client + .delete(cleanup_url.as_str()) + .send() + .await + .map_err(|err| err.to_string())?; + + if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(()); + } + + Err(format!("unexpected status {}", response.status())) +} + #[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "camelCase")] struct WakeLockConfig { @@ -388,98 +604,543 @@ fn should_allow_window_origin( url: &Url, ) -> bool { let state = app_handle.state::(); - if identity::local_window_id(window_label).is_err() && window_label != preferences_window::LABEL + if identity::local_window_id(window_label).is_ok() || window_label == preferences_window::LABEL { + let status = state.manager.status(); + return is_allowed_local_origin(url, status.url.as_deref()); + } + let Ok(allowed) = state.remote_navigation.lock() else { return false; + }; + should_allow_registered_origin( + allowed + .get(window_label) + .map(|metadata| metadata.origin.as_str()), + url, + ) +} + +fn should_allow_registered_origin(registered_origin: Option<&str>, url: &Url) -> bool { + if let Some(origin) = registered_origin { + return (matches!(url.scheme(), "http" | "https") + && origin == url.origin().ascii_serialization()) + || url.as_str() == "about:blank"; } - let status = state.manager.status(); - is_allowed_local_origin(url, status.url.as_deref()) + should_allow_internal(url) } fn should_open_external_url(url: &Url) -> bool { matches!(url.scheme(), "http" | "https" | "mailto") } -async fn remote_control_enabled(app: &AppHandle) -> bool { - let Some(access) = app.state::().manager.local_cli_access() else { - return false; +fn intercept_navigation(webview: &Webview, url: &Url) -> bool { + let window_label = webview.label().to_string(); + if should_allow_window_origin(&webview.app_handle(), &window_label, url) { + return true; + } + + if should_open_external_url(url) { + if let Err(err) = webview + .app_handle() + .opener() + .open_url(url.as_str(), None::<&str>) + { + eprintln!("[tauri] failed to open external link {}: {}", url, err); + } + } + false +} + +fn apply_remote_window_title(app_handle: &AppHandle, window_label: &str) { + let Some(title) = app_handle + .state::() + .remote_navigation + .lock() + .ok() + .and_then(|values| { + values + .get(window_label) + .map(|metadata| metadata.title.clone()) + }) + else { + return; }; - let Ok(mut url) = Url::parse(&access.base_url) else { - return false; + + if let Some(window) = app_handle.get_webview_window(window_label) { + let _ = window.set_title(&title); + } +} + +async fn open_remote_window_impl( + app: AppHandle, + payload: RemoteWindowPayload, +) -> Result<(), String> { + let label = format!("remote-{}", payload.id); + let requested_profile = RemoteProfileIdentity::new(payload.proxy_session_id.as_deref()); + let operation = app + .state::() + .remote_window_operations + .for_label(&label)?; + let _guard = operation.lock().await; + let result = open_remote_window_locked( + app.clone(), + payload, + label.clone(), + requested_profile.clone(), + ); + if result.is_err() { + if let Some(session_id) = requested_profile.proxy_session_id() { + schedule_remote_proxy_session_cleanup(app, label, session_id.to_string()); + } + } + result +} + +fn open_remote_window_locked( + app: AppHandle, + payload: RemoteWindowPayload, + label: String, + requested_profile: RemoteProfileIdentity, +) -> Result<(), String> { + require_http_url(&payload.base_url, "baseUrl")?; + let entry_url = payload + .entry_url + .as_deref() + .unwrap_or(payload.base_url.as_str()); + let parsed = require_http_url(entry_url, "entryUrl")?; + let title = format!("{} - {}", payload.name, payload.base_url); + + let window_url = parsed.clone(); + + let allow_linux_tls_certificate = parsed.scheme() == "https" + && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); + + let mut previous_profile = None; + + if let Some(existing) = app.get_webview_window(&label) { + previous_profile = app + .state::() + .remote_profiles + .lock() + .map_err(|err| err.to_string())? + .get(&label) + .cloned(); + if should_recreate_remote_window(previous_profile.as_ref(), &requested_profile) { + app.state::() + .remote_profiles + .lock() + .map_err(|err| err.to_string())? + .insert(label.clone(), requested_profile.clone()); + if let Err(error) = existing.destroy() { + let state = app.state::(); + let mut profiles = state + .remote_profiles + .lock() + .map_err(|err| err.to_string())?; + if let Some(previous) = previous_profile.as_ref() { + profiles.insert(label.clone(), previous.clone()); + } else { + profiles.remove(&label); + } + return Err(error.to_string()); + } + if let Ok(mut handlers) = app.state::().remote_tls_handlers.lock() { + handlers.remove(&label); + } + } else { + let staged = set_remote_window_metadata( + &app, + &label, + &window_url, + &title, + allow_linux_tls_certificate, + false, + )?; + #[cfg(target_os = "linux")] + if let Err(error) = linux_tls::ensure_remote_window_tls_handler( + &existing, + &app, + &label, + staged.window_generation, + ) { + restore_remote_window_metadata(&app, &label, staged); + return Err(error); + } + apply_remote_window_title(&app, &label); + if let Err(error) = existing.navigate(window_url.clone()) { + if restore_remote_window_metadata(&app, &label, staged) { + apply_remote_window_title(&app, &label); + } + return Err(error.to_string()); + } + apply_remote_window_title(&app, &label); + let _ = existing.show(); + let _ = existing.unminimize(); + let _ = existing.set_focus(); + return Ok(()); + } + } else { + app.state::() + .remote_profiles + .lock() + .map_err(|err| err.to_string())? + .insert(label.clone(), requested_profile.clone()); + } + + let staged = match set_remote_window_metadata( + &app, + &label, + &window_url, + &title, + allow_linux_tls_certificate, + true, + ) { + Ok(staged) => staged, + Err(error) => { + clear_remote_profile(&app, &label, &requested_profile); + if let Some(session_id) = previous_profile + .as_ref() + .and_then(RemoteProfileIdentity::proxy_session_id) + { + schedule_remote_proxy_session_cleanup( + app.clone(), + label.clone(), + session_id.to_string(), + ); + } + return Err(error); + } }; - url.set_path("/api/remote-control/status"); - url.set_query(None); - url.set_fragment(None); - let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(2)); - if url.scheme() == "https" { - let Ok(local_cert) = cert_manager::ensure_local_cert() else { - return false; - }; - let Ok(ca_cert) = reqwest::Certificate::from_der(&local_cert.ca_cert_der) else { - return false; + let window_generation = staged.window_generation; + + #[cfg(target_os = "linux")] + let initial_url = + if linux_tls::should_bootstrap_tls_navigation(&window_url, allow_linux_tls_certificate) { + Url::parse("about:blank").expect("about:blank is a valid URL") + } else { + window_url.clone() }; - builder = builder.add_root_certificate(ca_cert); + + #[cfg(not(target_os = "linux"))] + let initial_url = window_url.clone(); + + let profile_key = match requested_profile.proxy_session_id() { + Some(session_id) => format!("{}\0{session_id}", payload.id), + None => payload.id.clone(), + }; + let profile_hash = Sha256::digest(profile_key.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let data_directory = app + .state::() + .webview_data_directory + .join("remote") + .join(profile_hash); + let builder = WebviewWindowBuilder::new( + &app, + label.clone(), + WebviewUrl::External(initial_url.clone()), + ) + .data_directory(data_directory) + .incognito(requested_profile.proxy_session_id().is_some()) + .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) + .title(title) + .inner_size(1400.0, 900.0) + .min_inner_size(800.0, 600.0); + #[cfg(target_os = "macos")] + let builder = builder.data_store_identifier(profile_identifier(&profile_key)); + let window = match builder.build() { + Ok(window) => window, + Err(error) => { + cleanup_failed_remote_window( + &app, + None, + &label, + &requested_profile, + previous_profile.as_ref(), + window_generation, + ); + return Err(error.to_string()); + } + }; + + #[cfg(windows)] + if let Err(error) = shutdown::schedule_windows_session_end_handler(&window) { + cleanup_failed_remote_window( + &app, + Some(&window), + &label, + &requested_profile, + previous_profile.as_ref(), + window_generation, + ); + return Err(error); } - let Ok(client) = builder.build() else { - return false; + + #[cfg(target_os = "linux")] + { + let setup = + linux_tls::ensure_remote_window_tls_handler(&window, &app, &label, window_generation) + .and_then(|()| { + if initial_url == window_url { + Ok(()) + } else { + window + .navigate(window_url.clone()) + .map_err(|err| err.to_string()) + } + }); + if let Err(error) = setup { + cleanup_failed_remote_window( + &app, + Some(&window), + &label, + &requested_profile, + previous_profile.as_ref(), + window_generation, + ); + return Err(error); + } + } + + if let Some(session_id) = previous_profile + .as_ref() + .filter(|profile| *profile != &requested_profile) + .and_then(RemoteProfileIdentity::proxy_session_id) + { + schedule_remote_proxy_session_cleanup(app.clone(), label.clone(), session_id.to_string()); + } + + let app_handle = app.clone(); + let label_for_cleanup = label.clone(); + let profile_for_cleanup = requested_profile.clone(); + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Focused(_)) { + update_workspace_menu_state(&app_handle); + } + if let WindowEvent::Destroyed = event { + schedule_remote_window_destroyed_cleanup( + app_handle.clone(), + label_for_cleanup.clone(), + profile_for_cleanup.clone(), + window_generation, + ); + } + }); + + Ok(()) +} + +fn set_remote_window_metadata( + app: &AppHandle, + label: &str, + url: &Url, + title: &str, + allow_linux_tls_certificate: bool, + new_window: bool, +) -> Result { + let state = app.state::(); + let generation = state + .remote_navigation_generation + .fetch_add(1, Ordering::SeqCst) + + 1; + let mut values = state + .remote_navigation + .lock() + .map_err(|err| err.to_string())?; + let previous = values.get(label).cloned(); + let window_generation = if new_window { + generation + } else { + previous + .as_ref() + .map(|metadata| metadata.window_generation) + .unwrap_or(generation) }; - let Ok(response) = client - .get(url) - .header( - reqwest::header::COOKIE, - format!("{}={}", access.cookie_name, access.session_cookie), - ) - .send() - .await - else { + values.insert( + label.to_string(), + RemoteWindowMetadata { + origin: url.origin().ascii_serialization(), + title: title.to_string(), + allow_linux_tls_certificate, + generation, + window_generation, + }, + ); + Ok(StagedRemoteWindowMetadata { + generation, + window_generation, + previous, + }) +} + +fn clear_remote_tls_handler( + handlers: &mut HashMap, + label: &str, + window_generation: u64, +) -> bool { + if handlers.get(label).copied() != Some(window_generation) { return false; - }; - if !response.status().is_success() { + } + handlers.remove(label); + true +} + +fn rollback_remote_window_metadata( + values: &mut HashMap, + label: &str, + failed_generation: u64, + previous: Option, +) -> bool { + if values.get(label).map(|metadata| metadata.generation) != Some(failed_generation) { return false; } - response - .json::() - .await + match previous { + Some(previous) => { + values.insert(label.to_string(), previous); + } + None => { + values.remove(label); + } + } + true +} + +fn restore_remote_window_metadata( + app: &AppHandle, + label: &str, + staged: StagedRemoteWindowMetadata, +) -> bool { + app.state::() + .remote_navigation + .lock() .ok() - .and_then(|value| value.get("enabled").and_then(serde_json::Value::as_bool)) - .unwrap_or(false) + .is_some_and(|mut values| { + rollback_remote_window_metadata(&mut values, label, staged.generation, staged.previous) + }) } -fn request_final_local_window_close(app: AppHandle, label: String) { - tauri::async_runtime::spawn(async move { - let keep_alive = remote_control_enabled(&app).await; - app.state::() - .keep_alive_for_remote_control - .store(keep_alive, Ordering::SeqCst); - if keep_alive { - shutdown::request_local_window_close(app, label); - } else { - shutdown::request(app); +fn clear_remote_profile(app: &AppHandle, label: &str, profile: &RemoteProfileIdentity) -> bool { + let state = app.state::(); + let Ok(mut profiles) = state.remote_profiles.lock() else { + return false; + }; + if profiles.get(label) != Some(profile) { + return false; + } + profiles.remove(label); + true +} + +fn cleanup_failed_remote_window( + app: &AppHandle, + window: Option<&tauri::WebviewWindow>, + label: &str, + profile: &RemoteProfileIdentity, + previous_profile: Option<&RemoteProfileIdentity>, + window_generation: u64, +) { + if let Some(window) = window { + let _ = window.destroy(); + } + if clear_remote_window_metadata(app, label, profile, window_generation) { + if let Some(session_id) = previous_profile.and_then(RemoteProfileIdentity::proxy_session_id) + { + schedule_remote_proxy_session_cleanup( + app.clone(), + label.to_string(), + session_id.to_string(), + ); } - }); + } } -fn should_shutdown_after_last_window(keep_alive_for_remote_control: bool) -> bool { - !keep_alive_for_remote_control +fn clear_remote_window_metadata( + app: &AppHandle, + label: &str, + profile: &RemoteProfileIdentity, + window_generation: u64, +) -> bool { + let state = app.state::(); + let Ok(mut navigation) = state.remote_navigation.lock() else { + return false; + }; + if navigation + .get(label) + .map(|metadata| metadata.window_generation) + != Some(window_generation) + { + return false; + } + let Ok(mut profiles) = state.remote_profiles.lock() else { + return false; + }; + if profiles.get(label) != Some(profile) { + return false; + } + profiles.remove(label); + navigation.remove(label); + if let Ok(mut values) = state.remote_tls_handlers.lock() { + clear_remote_tls_handler(&mut values, label, window_generation); + } + true } -fn intercept_navigation(webview: &Webview, url: &Url) -> bool { - let window_label = webview.label().to_string(); - if should_allow_window_origin(&webview.app_handle(), &window_label, url) { - return true; +#[tauri::command] +fn needs_local_certificate_install( + window: tauri::WebviewWindow, + state: tauri::State, +) -> Result { + require_preferences_or_local_app_window(&window, &state)?; + #[cfg(not(target_os = "linux"))] + { + let local_cert = cert_manager::ensure_local_cert().map_err(|err| { + format!("Failed to load the local HTTPS certificate for the remote proxy window: {err}") + })?; + return cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| { + format!("Failed to inspect the local CodeNomad certificate trust state: {err}") + }); } - if should_open_external_url(url) { - if let Err(err) = webview - .app_handle() - .opener() - .open_url(url.as_str(), None::<&str>) - { - eprintln!("[tauri] failed to open external link {}: {}", url, err); + #[cfg(target_os = "linux")] + { + Ok(false) + } +} + +#[tauri::command] +async fn open_remote_window( + window: tauri::WebviewWindow, + app: AppHandle, + state: tauri::State<'_, AppState>, + payload: RemoteWindowPayload, +) -> Result<(), String> { + require_preferences_or_local_app_window(&window, &state)?; + #[cfg(not(target_os = "linux"))] + { + let entry_url = payload + .entry_url + .as_deref() + .unwrap_or(payload.base_url.as_str()); + require_http_url(&payload.base_url, "baseUrl")?; + let parsed = require_http_url(entry_url, "entryUrl")?; + if payload.proxy_session_id.is_some() && parsed.scheme() == "https" { + let local_cert = cert_manager::ensure_local_cert().map_err(|err| { + format!( + "Failed to load the local HTTPS certificate for the remote proxy window: {err}" + ) + })?; + if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) { + return Err(format!( + "Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}" + )); + } } } - false + + open_remote_window_impl(app, payload).await } fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec { @@ -607,6 +1268,13 @@ fn toggle_fullscreen_window(app_handle: &AppHandle) { fn set_target_zoom(app: &AppHandle, window: &tauri::WebviewWindow, zoom: f64) { if identity::local_window_id(window.label()).is_ok() { client_state::set_local_window_zoom(app, window.label(), zoom); + return; + } + let zoom = zoom.clamp(0.25, 5.0); + if window.set_zoom(zoom).is_ok() { + if let Ok(mut levels) = app.state::().remote_zoom_levels.lock() { + levels.insert(window.label().to_string(), zoom); + } } } @@ -614,7 +1282,12 @@ fn target_zoom(app: &AppHandle, window: &tauri::WebviewWindow) -> f64 { if identity::local_window_id(window.label()).is_ok() { client_state::local_window_zoom(app, window.label()) } else { - client_state::DEFAULT_ZOOM_LEVEL + app.state::() + .remote_zoom_levels + .lock() + .ok() + .and_then(|levels| levels.get(window.label()).copied()) + .unwrap_or(client_state::DEFAULT_ZOOM_LEVEL) } } @@ -741,6 +1414,125 @@ fn is_final_application_window<'a>( !labels.any(|label| label != closing_label && label != preferences_window::LABEL) } +async fn remote_control_enabled(app: &AppHandle) -> bool { + let Some(access) = app.state::().manager.local_cli_access() else { + return false; + }; + let Ok(mut url) = Url::parse(&access.base_url) else { + return false; + }; + url.set_path("/api/remote-control/status"); + url.set_query(None); + url.set_fragment(None); + + let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(2)); + if url.scheme() == "https" { + let Ok(local_cert) = cert_manager::ensure_local_cert() else { + return false; + }; + let Ok(ca_cert) = reqwest::Certificate::from_der(&local_cert.ca_cert_der) else { + return false; + }; + builder = builder.add_root_certificate(ca_cert); + } + let Ok(client) = builder.build() else { + return false; + }; + let Ok(response) = client + .get(url) + .header( + reqwest::header::COOKIE, + format!("{}={}", access.cookie_name, access.session_cookie), + ) + .send() + .await + else { + return false; + }; + if !response.status().is_success() { + return false; + } + response + .json::() + .await + .ok() + .and_then(|value| value.get("enabled").and_then(serde_json::Value::as_bool)) + .unwrap_or(false) +} + +fn consume_approved_auxiliary_close(app: &AppHandle, label: &str) -> bool { + app.state::() + .approved_auxiliary_closes + .lock() + .ok() + .is_some_and(|mut labels| labels.remove(label)) +} + +fn dispatch_approved_auxiliary_close(app: &AppHandle, label: &str) { + let approved = app + .state::() + .approved_auxiliary_closes + .lock() + .ok() + .is_some_and(|mut labels| labels.insert(label.to_string())); + if !approved { + return; + } + let dispatched = app + .get_webview_window(label) + .is_some_and(|window| window.close().is_ok()); + if !dispatched { + let _ = consume_approved_auxiliary_close(app, label); + } +} + +fn request_final_application_window_close(app: AppHandle, label: String, local_window: bool) { + tauri::async_runtime::spawn(async move { + let keep_alive = remote_control_enabled(&app).await; + app.state::() + .keep_alive_for_remote_control + .store(keep_alive, Ordering::SeqCst); + let windows = app.webview_windows(); + let still_final = is_final_application_window( + &label, + windows + .keys() + .filter(|candidate| !shutdown::local_window_close_in_flight(&app, candidate)) + .map(String::as_str), + ); + if should_shutdown_after_last_window(keep_alive, still_final) { + shutdown::request(app); + } else if local_window { + shutdown::request_local_window_close(app, label); + } else { + dispatch_approved_auxiliary_close(&app, &label); + } + }); +} + +fn should_shutdown_after_last_window( + keep_alive_for_remote_control: bool, + is_final_window: bool, +) -> bool { + is_final_window && !keep_alive_for_remote_control +} + +#[derive(Debug, PartialEq, Eq)] +enum LocalCloseEventAction { + Allow, + Prevent, + Continue, +} + +fn local_close_event_action(consumed: Option, in_flight: bool) -> LocalCloseEventAction { + match consumed { + Some(true) => LocalCloseEventAction::Allow, + Some(false) => LocalCloseEventAction::Prevent, + None if in_flight => LocalCloseEventAction::Prevent, + None => LocalCloseEventAction::Continue, + } +} + fn main() { #[cfg(windows)] if let Some(code) = cli_manager::run_windows_cli_launcher_if_requested() { @@ -836,6 +1628,14 @@ fn main() { manager: CliProcessManager::new(), developer_mode, wake_lock: Mutex::new(WakeLockState::default()), + remote_navigation: Mutex::new(HashMap::new()), + remote_navigation_generation: AtomicU64::new(0), + remote_profiles: Mutex::new(HashMap::new()), + remote_window_operations: RemoteWindowOperationLocks::default(), + remote_proxy_cleanup_claims: Mutex::new(HashSet::new()), + approved_auxiliary_closes: Mutex::new(HashSet::new()), + remote_tls_handlers: Mutex::new(HashMap::new()), + remote_zoom_levels: Mutex::new(HashMap::new()), workspace_menu_items: Mutex::new(None), webview_data_directory, developer_browser_arguments, @@ -866,6 +1666,12 @@ fn main() { .set_workspace_menu_enabled(webview.label(), false); update_workspace_menu_state(&webview.app_handle()); } + if matches!( + payload.event(), + PageLoadEvent::Started | PageLoadEvent::Finished + ) { + apply_remote_window_title(&webview.app_handle(), webview.label()); + } if webview.label() == preferences_window::LABEL && payload.event() == PageLoadEvent::Finished { @@ -905,6 +1711,7 @@ fn main() { cli_restart, wake_lock_start, wake_lock_stop, + needs_local_certificate_install, preferences_window::open_preferences_window, preferences_window::preferences_window_ready, preferences_window::preferences_get_request, @@ -912,6 +1719,7 @@ fn main() { preferences_window::preferences_resolve_transition, window_control, popup_titlebar_menu, + open_remote_window, client_state::client_state_claim_access, client_state::client_state_load, client_state::client_state_save, @@ -1050,10 +1858,11 @@ fn main() { return; } api.prevent_exit(); - if app_handle - .state::() - .keep_alive_for_remote_control - .load(Ordering::SeqCst) + if app_handle.webview_windows().is_empty() + && app_handle + .state::() + .keep_alive_for_remote_control + .load(Ordering::SeqCst) { return; } @@ -1120,25 +1929,25 @@ fn main() { } let local_window = identity::local_window_id(&label).is_ok(); if local_window { - match shutdown::consume_local_window_close(&app_handle, &label) { - Some(true) => return, - Some(false) => { + let consumed = shutdown::consume_local_window_close(&app_handle, &label); + let in_flight = shutdown::local_window_close_in_flight(&app_handle, &label); + match local_close_event_action(consumed, in_flight) { + LocalCloseEventAction::Allow => return, + LocalCloseEventAction::Prevent => { api.prevent_close(); return; } - None => {} + LocalCloseEventAction::Continue => {} } + } else if consume_approved_auxiliary_close(&app_handle, &label) { + return; } let windows = app_handle.webview_windows(); let final_window = is_final_application_window(&label, windows.keys().map(String::as_str)); if final_window { api.prevent_close(); - if local_window { - request_final_local_window_close(app_handle.clone(), label); - } else { - shutdown::request(app_handle.clone()); - } + request_final_application_window_close(app_handle.clone(), label, local_window); return; } if local_window { @@ -1151,6 +1960,8 @@ fn main() { event: tauri::WindowEvent::Destroyed, .. } => { + shutdown::local_window_destroyed(&app_handle, &label); + let _ = consume_approved_auxiliary_close(&app_handle, &label); if let Ok(window_id) = identity::local_window_id(&label) { app_handle .state::() @@ -1165,6 +1976,9 @@ fn main() { wake.handle.take(); } } + if let Ok(mut zoom) = app_handle.state::().remote_zoom_levels.lock() { + zoom.remove(&label); + } update_workspace_menu_state(&app_handle); update_fullscreen_shortcut(&app_handle); if !app_handle.webview_windows().is_empty() { @@ -1176,6 +1990,7 @@ fn main() { .state::() .keep_alive_for_remote_control .load(Ordering::SeqCst), + true, ) { return; } @@ -1493,9 +2308,13 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada #[cfg(test)] mod menu_tests { use super::{ - build_about_metadata, is_allowed_local_origin, is_final_application_window, - run_update_with_fallback, should_open_external_url, should_shutdown_after_last_window, - titlebar_menu_id, WakeLockState, RELEASES_URL, + build_about_metadata, claim_unowned_remote_proxy_session, clear_remote_tls_handler, + is_allowed_local_origin, is_final_application_window, local_close_event_action, + require_http_url, rollback_remote_window_metadata, run_update_with_fallback, + should_allow_registered_origin, should_open_external_url, should_recreate_remote_window, + should_shutdown_after_last_window, titlebar_menu_id, LocalCloseEventAction, + RemoteProfileIdentity, RemoteWindowMetadata, RemoteWindowOperationLocks, WakeLockState, + RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, }; use serde_json::json; use std::sync::atomic::{AtomicBool, Ordering}; @@ -1513,14 +2332,31 @@ mod menu_tests { )); assert!(!is_final_application_window( "local-a", - ["local-a", "preferences", "local-b"].into_iter(), + ["local-a", "preferences", "remote-a"].into_iter(), )); } #[test] - fn remote_control_keeps_the_backend_alive_without_windows() { - assert!(!should_shutdown_after_last_window(true)); - assert!(should_shutdown_after_last_window(false)); + fn remote_control_keeps_the_backend_alive_without_application_windows() { + assert!(!should_shutdown_after_last_window(true, true)); + assert!(should_shutdown_after_last_window(false, true)); + assert!(!should_shutdown_after_last_window(false, false)); + } + + #[test] + fn approved_local_close_wins_over_its_in_flight_marker() { + assert_eq!( + local_close_event_action(Some(true), true), + LocalCloseEventAction::Allow + ); + assert_eq!( + local_close_event_action(None, true), + LocalCloseEventAction::Prevent + ); + assert_eq!( + local_close_event_action(None, false), + LocalCloseEventAction::Continue + ); } #[test] @@ -1560,6 +2396,53 @@ mod menu_tests { assert_eq!(metadata.website_label, None); } + #[test] + fn remote_windows_identify_as_remote_tauri_windows() { + assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_RUNTIME_HOST__ = 'tauri'")); + assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_WINDOW_CONTEXT__ = 'remote'")); + + let capability: serde_json::Value = serde_json::from_str(include_str!( + "../capabilities/remote-window-notifications.json" + )) + .unwrap(); + assert_eq!(capability["local"], false); + assert_eq!( + capability["remote"]["urls"], + json!(["http://*:*", "https://*:*"]) + ); + assert_eq!(capability["windows"], json!(["remote-*"])); + assert_eq!( + capability["permissions"], + json!([ + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify" + ]) + ); + + let config: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + assert!(config["app"]["security"]["capabilities"] + .as_array() + .unwrap() + .contains(&json!("remote-window-notifications"))); + assert_eq!(config["app"]["windows"], json!([])); + let local: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/main-window.json")).unwrap(); + assert_eq!(local["windows"], json!(["local-*"])); + assert!(local["permissions"] + .as_array() + .unwrap() + .contains(&json!("allow-cli-restart"))); + assert!(!capability["permissions"] + .as_array() + .unwrap() + .iter() + .any(|permission| permission + .as_str() + .is_some_and(|value| value.starts_with("allow-cli")))); + } + #[test] fn wake_lock_request_labels_are_reference_counted() { let mut state = WakeLockState::default(); @@ -1572,6 +2455,108 @@ mod menu_tests { ); } + #[test] + fn remote_windows_stay_on_their_registered_http_origin() { + let origin = "https://remote.example:9898"; + assert!(should_allow_registered_origin( + Some(origin), + &Url::parse("https://remote.example:9898/settings").unwrap() + )); + assert!(!should_allow_registered_origin( + Some(origin), + &Url::parse("http://localhost:9898/").unwrap() + )); + assert!(should_allow_registered_origin( + Some(origin), + &Url::parse("about:blank").unwrap() + )); + } + + #[test] + fn failed_remote_navigation_restores_exact_previous_authority() { + let previous = RemoteWindowMetadata { + origin: "https://old.example".into(), + title: "Old title".into(), + allow_linux_tls_certificate: false, + generation: 4, + window_generation: 2, + }; + let mut values = std::collections::HashMap::from([( + "remote-a".to_string(), + RemoteWindowMetadata { + origin: "https://new.example".into(), + title: "New title".into(), + allow_linux_tls_certificate: true, + generation: 5, + window_generation: 2, + }, + )]); + + assert!(rollback_remote_window_metadata( + &mut values, + "remote-a", + 5, + Some(previous.clone()), + )); + assert_eq!(values.get("remote-a"), Some(&previous)); + } + + #[test] + fn stale_remote_navigation_failure_cannot_rollback_newer_authority() { + let current = RemoteWindowMetadata { + origin: "https://newest.example".into(), + title: "Newest title".into(), + allow_linux_tls_certificate: true, + generation: 6, + window_generation: 3, + }; + let mut values = + std::collections::HashMap::from([("remote-a".to_string(), current.clone())]); + + assert!(!rollback_remote_window_metadata( + &mut values, + "remote-a", + 5, + None, + )); + assert_eq!(values.get("remote-a"), Some(¤t)); + } + + #[test] + fn stale_window_cleanup_cannot_remove_replacement_tls_handler() { + let mut handlers = std::collections::HashMap::from([("remote-a".to_string(), 2)]); + + assert!(!clear_remote_tls_handler(&mut handlers, "remote-a", 1)); + assert_eq!(handlers.get("remote-a"), Some(&2)); + assert!(clear_remote_tls_handler(&mut handlers, "remote-a", 2)); + assert!(!handlers.contains_key("remote-a")); + } + + #[test] + fn remote_window_urls_require_http_or_https() { + assert_eq!( + require_http_url("http://localhost:3000/app", "baseUrl") + .unwrap() + .scheme(), + "http" + ); + assert_eq!( + require_http_url("https://example.com/app", "entryUrl") + .unwrap() + .scheme(), + "https" + ); + for value in [ + "file:///tmp/app", + "data:text/html,hi", + "javascript:alert(1)", + ] { + assert!(require_http_url(value, "baseUrl") + .unwrap_err() + .contains("must use HTTP or HTTPS")); + } + } + #[test] fn external_navigation_allows_only_web_and_mail_urls() { for value in [ @@ -1618,6 +2603,67 @@ mod menu_tests { || permission == "opener:allow-open-url")); } + #[test] + fn remote_window_reuse_requires_exact_profile_identity() { + let direct = RemoteProfileIdentity::Direct; + let proxy_a = RemoteProfileIdentity::Proxy("a".into()); + let proxy_b = RemoteProfileIdentity::Proxy("b".into()); + assert!(!should_recreate_remote_window(Some(&direct), &direct)); + assert!(!should_recreate_remote_window(Some(&proxy_a), &proxy_a)); + assert!(should_recreate_remote_window(Some(&direct), &proxy_a)); + assert!(should_recreate_remote_window(Some(&proxy_a), &direct)); + assert!(should_recreate_remote_window(Some(&proxy_a), &proxy_b)); + assert!(should_recreate_remote_window(None, &direct)); + } + + #[test] + fn remote_window_operations_serialize_only_matching_labels() { + let operations = RemoteWindowOperationLocks::default(); + let first = operations.for_label("remote-a").unwrap(); + let same = operations.for_label("remote-a").unwrap(); + let other = operations.for_label("remote-b").unwrap(); + + tauri::async_runtime::block_on(async { + let _guard = first.lock().await; + assert!(same.try_lock().is_err()); + assert!(other.try_lock().is_ok()); + }); + } + + #[test] + fn proxy_cleanup_is_claimed_once_and_never_while_owned() { + let mut profiles = std::collections::HashMap::from([( + "remote-a".to_string(), + RemoteProfileIdentity::Proxy("previous".into()), + )]); + let mut claims = std::collections::HashSet::new(); + + assert!(!claim_unowned_remote_proxy_session( + &profiles, + &mut claims, + "previous", + )); + profiles.insert( + "remote-a".into(), + RemoteProfileIdentity::Proxy("newer".into()), + ); + assert!(claim_unowned_remote_proxy_session( + &profiles, + &mut claims, + "previous", + )); + assert!(!claim_unowned_remote_proxy_session( + &profiles, + &mut claims, + "previous", + )); + assert!(!claim_unowned_remote_proxy_session( + &profiles, + &mut claims, + "newer", + )); + } + #[test] fn local_navigation_rejects_remote_and_unrelated_loopback_origins() { let managed = "http://127.0.0.1:43123"; diff --git a/packages/tauri-app/src-tauri/src/shutdown.rs b/packages/tauri-app/src-tauri/src/shutdown.rs index 56bc6d621..694417f67 100644 --- a/packages/tauri-app/src-tauri/src/shutdown.rs +++ b/packages/tauri-app/src-tauri/src/shutdown.rs @@ -1,5 +1,5 @@ use crate::{client_state, local_windows::LocalWindows, AppState}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Mutex; use std::time::{Duration, Instant}; #[cfg(windows)] @@ -37,6 +37,7 @@ struct ShutdownState { next_generation: u64, local_closes: HashMap, committed_local_closes: HashMap, + approved_local_closes: HashSet, global_pending: HashMap, global_requests: HashMap, shutdown_started: bool, @@ -66,6 +67,7 @@ impl ShutdownCoordinator { if state.shutdown_started || state.local_closes.contains_key(&label) || state.committed_local_closes.contains_key(&label) + || state.approved_local_closes.contains(&label) { return None; } @@ -214,6 +216,30 @@ impl ShutdownCoordinator { self.state.lock().ok()?.committed_local_closes.remove(label) } + fn local_close_in_flight(&self, label: &str) -> bool { + self.state.lock().map_or(true, |state| { + state.local_closes.contains_key(label) + || state.committed_local_closes.contains_key(label) + || state.approved_local_closes.contains(label) + }) + } + + fn approve_local_close(&self, label: String) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .approved_local_closes + .insert(label); + } + + fn local_window_destroyed(&self, label: &str) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .approved_local_closes + .remove(label); + } + fn exit_allowed(&self) -> bool { self.state .lock() @@ -601,10 +627,21 @@ pub(crate) fn with_navigation_authority( } pub(crate) fn consume_local_window_close(app: &AppHandle, label: &str) -> Option { + let coordinator = app.state::(); + if coordinator + .state + .lock() + .ok() + .is_some_and(|state| state.approved_local_closes.contains(label)) + { + return Some(true); + } let pending = app .state::() .take_committed_local_close(label)?; if !pending.persisted { + app.state::() + .approve_local_close(label.to_string()); return Some(true); } let result = app @@ -620,9 +657,21 @@ pub(crate) fn consume_local_window_close(app: &AppHandle, label: &str) -> Option emit_flush_cancelled(app, label, pending.generation); return Some(false); } + app.state::() + .approve_local_close(label.to_string()); Some(true) } +pub(crate) fn local_window_close_in_flight(app: &AppHandle, label: &str) -> bool { + app.state::() + .local_close_in_flight(label) +} + +pub(crate) fn local_window_destroyed(app: &AppHandle, label: &str) { + app.state::() + .local_window_destroyed(label); +} + pub(crate) fn exit_allowed(app: &AppHandle) -> bool { app.state::().exit_allowed() } diff --git a/packages/tauri-app/src-tauri/src/shutdown_tests.rs b/packages/tauri-app/src-tauri/src/shutdown_tests.rs index ae731910b..976274b24 100644 --- a/packages/tauri-app/src-tauri/src/shutdown_tests.rs +++ b/packages/tauri-app/src-tauri/src/shutdown_tests.rs @@ -110,6 +110,25 @@ fn committed_local_close_retains_exact_authority_until_consumed() { assert!(coordinator.take_committed_local_close("local-a").is_none()); } +#[test] +fn pending_and_approved_local_closes_remain_visible_to_final_window_decisions() { + let coordinator = ShutdownCoordinator::default(); + let generation = coordinator + .begin_local_close("local-a".into(), "window-a".into(), true) + .unwrap(); + assert!(coordinator.local_close_in_flight("local-a")); + let pending = coordinator + .acknowledge_local("local-a", "window-a", generation) + .unwrap(); + assert!(coordinator.commit_local_close("local-a".into(), pending)); + assert!(coordinator.local_close_in_flight("local-a")); + coordinator.take_committed_local_close("local-a").unwrap(); + coordinator.approve_local_close("local-a".into()); + assert!(coordinator.local_close_in_flight("local-a")); + coordinator.local_window_destroyed("local-a"); + assert!(!coordinator.local_close_in_flight("local-a")); +} + #[test] fn local_close_dispatch_rollback_reopens_close_authority() { let coordinator = ShutdownCoordinator::default(); diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index fec8d5786..66b0b7e56 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -15,7 +15,8 @@ "security": { "capabilities": [ "main-window-native-dialogs", - "preferences-window" + "preferences-window", + "remote-window-notifications" ] } }, diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 8adaadd02..d23869757 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -1,6 +1,6 @@ import { Dialog } from "@kobalte/core/dialog" import { Component, createMemo, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" -import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, X, Loader2, GitBranch, Pencil } from "lucide-solid" +import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, X, Globe, Loader2, GitBranch, Pencil } from "lucide-solid" import { useConfig } from "../stores/preferences" import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" @@ -16,13 +16,19 @@ import { showAlertDialog } from "../stores/alerts" import { openSettings, settingsOpen } from "../stores/settings-screen" import { openExternalUrl } from "../lib/external-url" import { serverApi } from "../lib/api-client" +import { canOpenRemoteWindows } from "../lib/runtime-env" import { getExistingInstanceForFolder, updateProjectNameForFolder } from "../stores/instances" import { LocaleSelector } from "./locale-selector" +import { RemoteServerDialog } from "./remote-server-dialog" +import { useRemoteServerProfiles } from "../lib/hooks/use-remote-server-profiles" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad" const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945" +type HomeTab = "local" | "servers" + + interface FolderSelectionViewProps { onSelectFolder: (folder: string) => void onSelectExistingInstance: (instanceId: string, recentPath: string) => void @@ -37,6 +43,7 @@ const FolderSelectionView: Component = (props) => { removeRecentFolder, renameRecentFolderProject, } = useConfig() + const { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } = useRemoteServerProfiles() const { t } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [hoveredRecentActionPath, setHoveredRecentActionPath] = createSignal(null) @@ -52,15 +59,19 @@ const FolderSelectionView: Component = (props) => { const [cleanupCloneDestination, setCleanupCloneDestination] = createSignal(false) const [cloneDialogError, setCloneDialogError] = createSignal(null) const [isCloningRepository, setIsCloningRepository] = createSignal(false) + const [activeTab, setActiveTab] = createSignal("local") + const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false) let homeRootRef: HTMLDivElement | undefined let actionsColumnRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined const folders = () => recentFolders() + const serverList = () => remoteServers() const isLoading = () => Boolean(props.isLoading) + const canUseRemoteServerWindows = () => canOpenRemoteWindows() function getActiveListLength() { - return folders().length + return activeTab() === "local" ? folders().length : serverList().length } function scrollToIndex(index: number) { @@ -165,10 +176,30 @@ const FolderSelectionView: Component = (props) => { if (isLoading()) return const index = selectedIndex() - const folder = folders()[index] - if (folder) handleFolderSelect(folder.path) + if (activeTab() === "local") { + const folder = folders()[index] + if (folder) { + handleFolderSelect(folder.path) + } + return + } + + const server = serverList()[index] + if (server) { + void connectSavedServer(server.id) + } } + createEffect(() => { + activeTab() + if (!canUseRemoteServerWindows() && activeTab() !== "local") { + setActiveTab("local") + return + } + setSelectedIndex(0) + setFocusMode("recent") + }) + createEffect(() => { const length = getActiveListLength() if (length === 0) { @@ -311,6 +342,11 @@ const FolderSelectionView: Component = (props) => { } } + function openServerDialog() { + if (!canUseRemoteServerWindows()) return + setIsServerDialogOpen(true) + } + async function handleBrowse() { if (isLoading()) return setFocusMode("new") @@ -569,20 +605,154 @@ const FolderSelectionView: Component = (props) => { {/* Right column: recent folders */}
-
-
-
{t("folderSelection.recent.title")}
-

- {t( - folders().length === 1 - ? "folderSelection.recent.subtitle.one" - : "folderSelection.recent.subtitle.other", - { count: folders().length }, - )} -

+
+
+ + + +
+ 0} + fallback={ + +
+
+ +
+

{t("folderSelection.servers.empty.title")}

+

{t("folderSelection.servers.empty.description")}

+ +
+
+ } + > +
(recentListRef = el)} + > + + {(server, index) => ( +
+
+ + +
+
+ )} +
+
+
+ } + > 0} fallback={ @@ -725,6 +895,7 @@ const FolderSelectionView: Component = (props) => {
+
@@ -781,6 +952,17 @@ const FolderSelectionView: Component = (props) => {
+ + + {/* OpenCode settings section */} @@ -967,6 +1149,7 @@ const FolderSelectionView: Component = (props) => { + ) } diff --git a/packages/ui/src/components/remote-access-overlay.tsx b/packages/ui/src/components/remote-access-overlay.tsx new file mode 100644 index 000000000..7b6a87253 --- /dev/null +++ b/packages/ui/src/components/remote-access-overlay.tsx @@ -0,0 +1,520 @@ +import { Dialog } from "@kobalte/core/dialog" +import { Switch } from "@kobalte/core/switch" +import { For, Show, createEffect, createMemo, createSignal } from "solid-js" +import { toDataURL } from "qrcode" +import { ChevronRight, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" +import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" +import { serverApi } from "../lib/api-client" +import { restartCli } from "../lib/native/cli" +import { serverSettings, setListeningMode } from "../stores/preferences" +import { showConfirmDialog } from "../stores/alerts" +import { getLogger } from "../lib/logger" +import { useI18n } from "../lib/i18n" +import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses" +const log = getLogger("actions") + + +interface RemoteAccessOverlayProps { + open: boolean + onClose: () => void +} + +export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { + const { t } = useI18n() + const [meta, setMeta] = createSignal(null) + const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null) + const [loading, setLoading] = createSignal(false) + const [applyingListeningMode, setApplyingListeningMode] = createSignal(false) + const [qrCodes, setQrCodes] = createSignal>({}) + const [expandedUrl, setExpandedUrl] = createSignal(null) + const [error, setError] = createSignal(null) + const [passwordFormOpen, setPasswordFormOpen] = createSignal(false) + const [passwordValue, setPasswordValue] = createSignal("") + const [passwordConfirm, setPasswordConfirm] = createSignal("") + const [passwordError, setPasswordError] = createSignal(null) + const [savingPassword, setSavingPassword] = createSignal(false) + const [showAllAddresses, setShowAllAddresses] = createSignal(false) + + const addresses = createMemo(() => meta()?.addresses ?? []) + const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) + const allowExternalConnections = createMemo(() => currentMode() === "all") + const displayAddresses = createMemo(() => { + const list = addresses() + if (!allowExternalConnections()) { + return { recommended: null, hidden: [] } + } + return splitRemoteAddresses(list) + }) + + const refreshMeta = async () => { + setLoading(true) + setError(null) + setPasswordError(null) + try { + const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) + setMeta(metaResult) + setAuthStatus(authResult) + setShowAllAddresses(false) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + } + + createEffect(() => { + if (props.open) { + void refreshMeta() + } + }) + + const toggleExpanded = async (url: string) => { + if (expandedUrl() === url) { + setExpandedUrl(null) + return + } + setExpandedUrl(url) + if (!qrCodes()[url]) { + try { + const dataUrl = await toDataURL(url, { margin: 1, scale: 4 }) + setQrCodes((prev) => ({ ...prev, [url]: dataUrl })) + } catch (err) { + log.error("Failed to generate QR code", err) + } + } + } + + const handleAllowConnectionsChange = async (checked: boolean) => { + const allow = Boolean(checked) + const targetMode: "local" | "all" = allow ? "all" : "local" + if (targetMode === currentMode()) { + return + } + + if (applyingListeningMode()) { + return + } + + const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), { + title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"), + variant: "warning", + confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"), + cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"), + dismissible: false, + }) + + if (!confirmed) { + // Switch will revert automatically since `checked` is derived from store state + return + } + + setApplyingListeningMode(true) + setError(null) + try { + // Important: await the config patch before restart so Electron reads the updated mode from disk. + await setListeningMode(targetMode) + const restarted = await restartCli() + if (!restarted) { + setError(t("remoteAccess.restart.errorManual")) + } else { + setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setApplyingListeningMode(false) + } + + void refreshMeta() + } + + const handleOpenUrl = (url: string) => { + try { + window.open(url, "_blank", "noopener,noreferrer") + } catch (err) { + log.error("Failed to open URL", err) + } + } + + const handleSubmitPassword = async () => { + setPasswordError(null) + + const next = passwordValue() + const confirm = passwordConfirm() + + if (next.trim().length < 8) { + setPasswordError(t("remoteAccess.password.error.tooShort")) + return + } + + if (next !== confirm) { + setPasswordError(t("remoteAccess.password.error.mismatch")) + return + } + + setSavingPassword(true) + try { + const result = await serverApi.setServerPassword(next) + setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided }) + setPasswordValue("") + setPasswordConfirm("") + setPasswordFormOpen(false) + } catch (err) { + setPasswordError(err instanceof Error ? err.message : String(err)) + } finally { + setSavingPassword(false) + } + } + + return ( + { + if (!nextOpen) { + props.onClose() + } + }} + > + + +
+ +
+
+

{t("remoteAccess.eyebrow")}

+

{t("remoteAccess.title")}

+

{t("remoteAccess.subtitle")}

+
+ +
+ +
+
+
+
+ +
+

{t("remoteAccess.sections.listeningMode.label")}

+

{t("remoteAccess.sections.listeningMode.help")}

+
+
+ +
+ + { + void handleAllowConnectionsChange(nextChecked) + }} + disabled={loading() || applyingListeningMode()} + > + + + {allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")} + + +
+ {t("remoteAccess.toggle.title")} + + {allowExternalConnections() ? t("remoteAccess.toggle.caption.all") : t("remoteAccess.toggle.caption.local")} + +
+
+

+ {t("remoteAccess.toggle.note")} +

+
+ +
+
+
+ +
+

{t("remoteAccess.sections.serverPassword.label")}

+

{t("remoteAccess.sections.serverPassword.help")}

+
+
+
+ + {t("remoteAccess.authStatus.unavailable")}
} + > +
+

+ {t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })} +

+

+ {authStatus()!.passwordUserProvided + ? t("remoteAccess.password.status.set") + : t("remoteAccess.password.status.unset")} +

+ +
+ +
+ + +
+ + setPasswordValue(event.currentTarget.value)} + placeholder={t("remoteAccess.password.form.placeholder")} + /> +
+
+ + setPasswordConfirm(event.currentTarget.value)} + /> +
+ + + {(message) =>
{message()}
} +
+ +
+ +
+
+
+ + + +
+ +
+
+ +
+

{t("remoteAccess.sections.addresses.label")}

+

{t("remoteAccess.sections.addresses.help")}

+
+
+
+ + {t("remoteAccess.addresses.loading")}
}> + {error()}}> + {t("remoteAccess.addresses.none")}}> +
+ + {(url) => { + const value = () => url() + const expandedState = () => expandedUrl() === value() + const qr = () => qrCodes()[value()] + return ( +
+
+
+

{value()}

+

{t("remoteAccess.address.scope.loopback")}

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+ + {(addressAccessor) => { + const address = addressAccessor() + const url = address.remoteUrl + const expandedState = () => expandedUrl() === url + const qr = () => qrCodes()[url] + const scopeLabel = () => + address.scope === "external" + ? t("remoteAccess.address.scope.network") + : address.scope === "loopback" + ? t("remoteAccess.address.scope.loopback") + : t("remoteAccess.address.scope.internal") + + return ( +
+
+
+

{url}

+

+ {address.family.toUpperCase()} - {scopeLabel()} - {address.ip} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+ + 0}> +
+ + + +
+ + {(address) => { + const url = address.remoteUrl + const expandedState = () => expandedUrl() === url + const qr = () => qrCodes()[url] + const scopeLabel = () => + address.scope === "external" + ? t("remoteAccess.address.scope.network") + : address.scope === "loopback" + ? t("remoteAccess.address.scope.loopback") + : t("remoteAccess.address.scope.internal") + return ( +
+
+
+

{url}

+

+ {address.family.toUpperCase()} • {scopeLabel()} • {address.ip} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+
+
+
+
+
+
+
+ + + + + +
+
+ ) +} diff --git a/packages/ui/src/components/remote-server-dialog.tsx b/packages/ui/src/components/remote-server-dialog.tsx new file mode 100644 index 000000000..dc5509f37 --- /dev/null +++ b/packages/ui/src/components/remote-server-dialog.tsx @@ -0,0 +1,80 @@ +import { Dialog } from "@kobalte/core/dialog" +import { Loader2 } from "lucide-solid" +import { createEffect, createSignal, Show, type Component } from "solid-js" +import { useI18n } from "../lib/i18n" +import type { RemoteServerInput } from "../lib/hooks/use-remote-server-profiles" + +interface RemoteServerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (input: RemoteServerInput, openWindow: boolean) => Promise +} + +export const RemoteServerDialog: Component = (props) => { + const { t } = useI18n() + const [name, setName] = createSignal("") + const [baseUrl, setBaseUrl] = createSignal("") + const [skipTlsVerify, setSkipTlsVerify] = createSignal(false) + const [error, setError] = createSignal(null) + const [busy, setBusy] = createSignal(false) + + createEffect(() => { + if (!props.open) return + setName("") + setBaseUrl("") + setSkipTlsVerify(false) + setError(null) + }) + + const submit = async (openWindow: boolean) => { + if (busy()) return + setBusy(true) + setError(null) + try { + await props.onSubmit({ name: name(), baseUrl: baseUrl(), skipTlsVerify: skipTlsVerify() }, openWindow) + props.onOpenChange(false) + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : String(submitError)) + } finally { + setBusy(false) + } + } + + return ( + + + +
+ +
+ {t("folderSelection.servers.dialog.title")} + {t("folderSelection.servers.dialog.description")} +
+ + + + {(message) =>

{message()}

}
+
+ + + +
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx index 80361a8c5..dd296345e 100644 --- a/packages/ui/src/components/settings-screen.tsx +++ b/packages/ui/src/components/settings-screen.tsx @@ -21,8 +21,11 @@ import { ProvidersSettingsSection } from "./settings/providers-settings-section" import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { AdvancedSettingsSection } from "./settings/advanced-settings-section" import { ConfigFilesSettingsSection } from "./settings/config-files-settings-section" +import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" import { RemoteControlSettingsSection } from "./settings/remote-control-settings-section" +import { SavedRemoteServersCard } from "./settings/saved-remote-servers-card" import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" +import { canOpenRemoteWindows } from "../lib/runtime-env" import { confirmSettingsDiscard } from "../stores/settings-dirty-guard" import { NativeTitlebar } from "./native-titlebar" @@ -74,6 +77,10 @@ export const SettingsScreen: Component = (props) => { return (
+ + + +
) case "opencode": diff --git a/packages/ui/src/components/settings/remote-access-settings-section.tsx b/packages/ui/src/components/settings/remote-access-settings-section.tsx new file mode 100644 index 000000000..4c9fa3730 --- /dev/null +++ b/packages/ui/src/components/settings/remote-access-settings-section.tsx @@ -0,0 +1,487 @@ +import { Switch } from "@kobalte/core/switch" +import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js" +import { toDataURL } from "qrcode" +import { ChevronRight, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" +import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types" +import { serverApi } from "../../lib/api-client" +import { restartCli } from "../../lib/native/cli" +import { serverSettings, setListeningMode } from "../../stores/preferences" +import { showConfirmDialog } from "../../stores/alerts" +import { getLogger } from "../../lib/logger" +import { useI18n } from "../../lib/i18n" +import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses" + +const log = getLogger("actions") + +export const RemoteAccessSettingsSection: Component = () => { + const { t } = useI18n() + const [meta, setMeta] = createSignal(null) + const [authStatus, setAuthStatus] = createSignal<{ + authenticated: boolean + username?: string + passwordUserProvided?: boolean + } | null>(null) + const [loading, setLoading] = createSignal(false) + const [applyingListeningMode, setApplyingListeningMode] = createSignal(false) + const [qrCodes, setQrCodes] = createSignal>({}) + const [expandedUrl, setExpandedUrl] = createSignal(null) + const [error, setError] = createSignal(null) + const [passwordFormOpen, setPasswordFormOpen] = createSignal(false) + const [passwordValue, setPasswordValue] = createSignal("") + const [passwordConfirm, setPasswordConfirm] = createSignal("") + const [passwordError, setPasswordError] = createSignal(null) + const [savingPassword, setSavingPassword] = createSignal(false) + const [showAllAddresses, setShowAllAddresses] = createSignal(false) + + const addresses = createMemo(() => meta()?.addresses ?? []) + const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) + const allowExternalConnections = createMemo(() => currentMode() === "all") + const displayAddresses = createMemo(() => { + const list = addresses() + if (!allowExternalConnections()) return { recommended: null, hidden: [] } + return splitRemoteAddresses(list) + }) + + const refreshMeta = async () => { + setLoading(true) + setError(null) + setPasswordError(null) + try { + const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) + setMeta(metaResult) + setAuthStatus(authResult) + setShowAllAddresses(false) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + } + + onMount(() => { + void refreshMeta() + }) + + const toggleExpanded = async (url: string) => { + if (expandedUrl() === url) { + setExpandedUrl(null) + return + } + setExpandedUrl(url) + if (!qrCodes()[url]) { + try { + const dataUrl = await toDataURL(url, { margin: 1, scale: 4 }) + setQrCodes((prev) => ({ ...prev, [url]: dataUrl })) + } catch (err) { + log.error("Failed to generate QR code", err) + } + } + } + + const handleAllowConnectionsChange = async (checked: boolean) => { + const targetMode: "local" | "all" = checked ? "all" : "local" + if (targetMode === currentMode() || applyingListeningMode()) return + + const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), { + title: checked + ? t("remoteAccess.listeningMode.restartConfirm.title.all") + : t("remoteAccess.listeningMode.restartConfirm.title.local"), + variant: "warning", + confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"), + cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"), + dismissible: false, + }) + + if (!confirmed) return + + setApplyingListeningMode(true) + setError(null) + try { + await setListeningMode(targetMode) + const restarted = await restartCli() + if (!restarted) { + setError(t("remoteAccess.restart.errorManual")) + } else { + setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setApplyingListeningMode(false) + } + + void refreshMeta() + } + + const handleOpenUrl = (url: string) => { + try { + window.open(url, "_blank", "noopener,noreferrer") + } catch (err) { + log.error("Failed to open URL", err) + } + } + + const handleSubmitPassword = async () => { + setPasswordError(null) + + const next = passwordValue() + const confirm = passwordConfirm() + if (next.trim().length < 8) { + setPasswordError(t("remoteAccess.password.error.tooShort")) + return + } + if (next !== confirm) { + setPasswordError(t("remoteAccess.password.error.mismatch")) + return + } + + setSavingPassword(true) + try { + const result = await serverApi.setServerPassword(next) + setAuthStatus({ + authenticated: true, + username: result.username, + passwordUserProvided: result.passwordUserProvided, + }) + setPasswordValue("") + setPasswordConfirm("") + setPasswordFormOpen(false) + } catch (err) { + setPasswordError(err instanceof Error ? err.message : String(err)) + } finally { + setSavingPassword(false) + } + } + + return ( +
+
+
+
+ +
+

{t("remoteAccess.sections.listeningMode.label")}

+

{t("remoteAccess.sections.listeningMode.help")}

+
+
+
+ {t("settings.scope.server")} + +
+
+ + void handleAllowConnectionsChange(nextChecked)} + disabled={loading() || applyingListeningMode()} + > + + + + {allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")} + + + +
+ {t("remoteAccess.toggle.title")} + + {allowExternalConnections() + ? t("remoteAccess.toggle.caption.all") + : t("remoteAccess.toggle.caption.local")} + +
+
+ +

{t("remoteAccess.toggle.note")}

+
+ +
+
+
+ +
+

{t("remoteAccess.sections.serverPassword.label")}

+

{t("remoteAccess.sections.serverPassword.help")}

+
+
+ {t("settings.scope.server")} +
+ + {t("remoteAccess.authStatus.unavailable")}
} + > +
+
+
+

{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}

+

+ {authStatus()!.passwordUserProvided + ? t("remoteAccess.password.status.set") + : t("remoteAccess.password.status.unset")} +

+
+ +
+ +
+
+ + +
+ + setPasswordValue(event.currentTarget.value)} + placeholder={t("remoteAccess.password.form.placeholder")} + /> +
+
+ + setPasswordConfirm(event.currentTarget.value)} + /> +
+ + + {(message) =>
{message()}
} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+

{t("remoteAccess.sections.addresses.label")}

+

{t("remoteAccess.sections.addresses.help")}

+
+
+ {t("settings.scope.server")} +
+ + {t("remoteAccess.addresses.loading")}
}> + {error()}}> + {t("remoteAccess.addresses.none")}} + > +
+ + {(url) => { + const value = () => url() + const expandedState = () => expandedUrl() === value() + const qr = () => qrCodes()[value()] + return ( +
+
+
+

{value()}

+

{t("remoteAccess.address.scope.loopback")}

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+ + + {(addressAccessor) => { + const address = addressAccessor() + const url = address.remoteUrl + const expandedState = () => expandedUrl() === url + const qr = () => qrCodes()[url] + const scopeLabel = () => + address.scope === "external" + ? t("remoteAccess.address.scope.network") + : address.scope === "loopback" + ? t("remoteAccess.address.scope.loopback") + : t("remoteAccess.address.scope.internal") + + return ( +
+
+
+

{url}

+

+ {address.family.toUpperCase()} - {scopeLabel()} - {address.ip} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+ + 0}> +
+ + + +
+ + {(address) => { + const url = address.remoteUrl + const expandedState = () => expandedUrl() === url + const qr = () => qrCodes()[url] + const scopeLabel = () => + address.scope === "external" + ? t("remoteAccess.address.scope.network") + : address.scope === "loopback" + ? t("remoteAccess.address.scope.loopback") + : t("remoteAccess.address.scope.internal") + + return ( +
+
+
+

{url}

+

+ {address.family.toUpperCase()} - {scopeLabel()} - {address.ip} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+
+
+
+
+
+
+
+ + + + ) +} diff --git a/packages/ui/src/components/settings/remote-control-settings-section.tsx b/packages/ui/src/components/settings/remote-control-settings-section.tsx index b0e070027..3b5fccf86 100644 --- a/packages/ui/src/components/settings/remote-control-settings-section.tsx +++ b/packages/ui/src/components/settings/remote-control-settings-section.tsx @@ -57,8 +57,9 @@ export const RemoteControlSettingsSection: Component = () => { const deviceResult = await serverApi.fetchRemoteControlDevices() setDevices(deviceResult.devices) } catch (cause) { - setError(message(cause)) + const failure = message(cause) await refresh() + setError(failure) } finally { setLoading(false) } diff --git a/packages/ui/src/components/settings/saved-remote-servers-card.tsx b/packages/ui/src/components/settings/saved-remote-servers-card.tsx new file mode 100644 index 000000000..095457b81 --- /dev/null +++ b/packages/ui/src/components/settings/saved-remote-servers-card.tsx @@ -0,0 +1,67 @@ +import { Globe, Loader2, Plus, Trash2 } from "lucide-solid" +import { createSignal, For, Show, type Component } from "solid-js" +import { useI18n } from "../../lib/i18n" +import { useRemoteServerProfiles } from "../../lib/hooks/use-remote-server-profiles" +import { RemoteServerDialog } from "../remote-server-dialog" + +export const SavedRemoteServersCard: Component = () => { + const { t } = useI18n() + const { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } = useRemoteServerProfiles() + const [dialogOpen, setDialogOpen] = createSignal(false) + + return ( + <> +
+
+
+ +
+

{t("folderSelection.tabs.servers")}

+

{t("folderSelection.servers.empty.description")}

+
+
+ +
+
+ 0} fallback={
{t("folderSelection.servers.empty.title")}
}> + + {(server) => ( +
+
+
{server.name}
+
{server.baseUrl}
+
+
+ + +
+
+ )} +
+
+
+
+ + + ) +} diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 4d42ab490..19461141e 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -17,6 +17,10 @@ import type { PreviewSession, ProviderUsageResponse, ServerMeta, + RemoteProxySessionCreateRequest, + RemoteProxySessionCreateResponse, + RemoteServerProbeRequest, + RemoteServerProbeResponse, RemoteControlDevice, RemoteControlPairing, RemoteControlStartResponse, @@ -262,6 +266,21 @@ export const serverApi = { fetchServerMeta(): Promise { return request("/api/meta") }, + probeRemoteServer(payload: RemoteServerProbeRequest): Promise { + return request("/api/remote-servers/probe", { + method: "POST", + body: JSON.stringify(payload), + }) + }, + createRemoteProxySession(payload: RemoteProxySessionCreateRequest): Promise { + return request("/api/remote-proxy/sessions", { + method: "POST", + body: JSON.stringify(payload), + }) + }, + deleteRemoteProxySession(id: string): Promise { + return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }) + }, fetchRemoteControlStatus(): Promise { return request("/api/remote-control/status") }, diff --git a/packages/ui/src/lib/hooks/use-remote-server-profiles.ts b/packages/ui/src/lib/hooks/use-remote-server-profiles.ts new file mode 100644 index 000000000..1fea22b11 --- /dev/null +++ b/packages/ui/src/lib/hooks/use-remote-server-profiles.ts @@ -0,0 +1,77 @@ +import { createSignal } from "solid-js" +import { serverApi } from "../api-client" +import { useI18n } from "../i18n" +import { openRemoteServerWindow } from "../native/remote-window" +import { canOpenRemoteWindows, isTauriHost } from "../runtime-env" +import { showAlertDialog } from "../../stores/alerts" +import { useConfig } from "../../stores/preferences" + +export type RemoteServerInput = { + id?: string + name: string + baseUrl: string + skipTlsVerify: boolean +} + +export function useRemoteServerProfiles() { + const { t } = useI18n() + const { remoteServers, saveRemoteServerProfile, markRemoteServerConnected, removeRemoteServerProfile } = useConfig() + const [connectingServerId, setConnectingServerId] = createSignal(null) + + const saveServer = async (input: RemoteServerInput, openWindow: boolean) => { + if (openWindow && !canOpenRemoteWindows()) { + throw new Error("Remote server windows can only be opened from a local desktop window") + } + + const name = input.name.trim() + const baseUrl = input.baseUrl.trim() + if (!name || !baseUrl) throw new Error(t("folderSelection.servers.dialog.errorRequired")) + + const probe = await serverApi.probeRemoteServer({ baseUrl, skipTlsVerify: input.skipTlsVerify }) + if (!probe.ok) throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect")) + + const profile = await saveRemoteServerProfile({ + id: input.id, + name, + baseUrl: probe.normalizedUrl, + skipTlsVerify: input.skipTlsVerify, + }) + + if (openWindow) { + const proxySession = + isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://") + ? await serverApi.createRemoteProxySession({ baseUrl: profile.baseUrl, skipTlsVerify: true }) + : undefined + + try { + await openRemoteServerWindow(profile, proxySession?.windowUrl, proxySession?.sessionId) + } catch (error) { + if (proxySession) void serverApi.deleteRemoteProxySession(proxySession.sessionId).catch(() => {}) + throw error + } + await markRemoteServerConnected(profile.id) + } + + return profile + } + + const connectSavedServer = async (id: string) => { + if (!canOpenRemoteWindows() || connectingServerId()) return + const target = remoteServers().find((server) => server.id === id) + if (!target) return + + setConnectingServerId(id) + try { + await saveServer(target, true) + } catch (error) { + showAlertDialog(error instanceof Error ? error.message : String(error), { + title: t("folderSelection.servers.errorTitle"), + variant: "warning", + }) + } finally { + setConnectingServerId(null) + } + } + + return { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } +} diff --git a/packages/ui/src/lib/i18n/messages/de/folderSelection.ts b/packages/ui/src/lib/i18n/messages/de/folderSelection.ts index 83f2dc2ff..ac1d18079 100644 --- a/packages/ui/src/lib/i18n/messages/de/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/de/folderSelection.ts @@ -41,6 +41,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "Repository-URL und Zielordner sind erforderlich.", "folderSelection.actions.title": "Ordner öffnen oder Server verbinden", "folderSelection.actions.subtitle": "Lokalen Ordner öffnen oder mit einem CodeNomad-Server verbinden", + "folderSelection.actions.connectButton": "CodeNomad-Server verbinden", "folderSelection.advancedSettings": "Erweiterte Einstellungen", "folderSelection.opencode": "OpenCode", @@ -62,6 +63,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Wählen Sie einen Arbeitsbereich aus, um mit dem Codieren zu beginnen.", "folderSelection.tabs.local": "Lokale Ordner", + "folderSelection.tabs.servers": "Server", + "folderSelection.servers.title": "Gespeicherte Server", + "folderSelection.servers.subtitle": "Einen gespeicherten CodeNomad-Remote-Server in einem neuen Fenster öffnen", + "folderSelection.servers.count": "{count} Server", + "folderSelection.servers.empty.title": "Keine gespeicherten Server", + "folderSelection.servers.empty.description": "Fügen Sie einen Remote-Server hinzu, um sich von diesem Gerät aus schnell wieder zu verbinden", + "folderSelection.servers.connectTitle": "Mit Server verbinden", + "folderSelection.servers.connectSubtitle": "Einen CodeNomad-Remote-Server speichern und in einem neuen Fenster öffnen", + "folderSelection.servers.connectButton": "Mit Server verbinden", + "folderSelection.servers.remove": "Gespeicherten Server entfernen", + "folderSelection.servers.skipTls": "Selbstsigniertes TLS", + "folderSelection.servers.errorTitle": "Remote-Verbindung fehlgeschlagen", + "folderSelection.servers.dialog.title": "Mit Server verbinden", + "folderSelection.servers.dialog.description": "Fügen Sie einen CodeNomad-Remote-Server hinzu und öffnen Sie ihn optional sofort.", + "folderSelection.servers.dialog.name": "Servername", + "folderSelection.servers.dialog.namePlaceholder": "Produktionsserver", + "folderSelection.servers.dialog.url": "Server-URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.beispiel.de", + "folderSelection.servers.dialog.skipTls": "TLS-Verifizierung für selbstsignierte Zertifikate überspringen.", + "folderSelection.servers.dialog.cancel": "Abbrechen", + "folderSelection.servers.dialog.save": "Speichern", + "folderSelection.servers.dialog.connect": "Verbinden", + "folderSelection.servers.dialog.connecting": "Verbindung wird hergestellt...", + "folderSelection.servers.dialog.errorRequired": "Servername und URL sind erforderlich.", + "folderSelection.servers.dialog.errorConnect": "Verbindung zum Remote-Server konnte nicht hergestellt werden.", + "folderSelection.servers.certificateInstall.title": "Lokales Zertifikat installieren", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad muss ein lokales Zertifikat installieren, um selbstsignierte HTTPS-Remote-Fenster zu öffnen. Dieses Zertifikat wird nur für den lokalen Desktop-Proxy-Verkehr auf Ihrem Rechner verwendet. Ihr Betriebssystem zeigt danach möglicherweise eine zweite Zertifikatsabfrage an.", + "folderSelection.servers.certificateInstall.confirmLabel": "Weiter", + "folderSelection.servers.certificateInstall.cancelLabel": "Abbrechen", + "folderSelection.servers.certificateInstall.cancelled": "CodeNomad benötigt das Vertrauen in das lokale Zertifikat, bevor es selbstsignierte HTTPS-Remote-Fenster öffnen kann.", "folderSelection.sidecars.button": "SideCar öffnen", "projectRenameDialog.title": "Arbeitsbereich umbenennen", diff --git a/packages/ui/src/lib/i18n/messages/de/index.ts b/packages/ui/src/lib/i18n/messages/de/index.ts index 3321544f3..741bb7846 100644 --- a/packages/ui/src/lib/i18n/messages/de/index.ts +++ b/packages/ui/src/lib/i18n/messages/de/index.ts @@ -9,6 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -31,6 +32,7 @@ export const deMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, + remoteAccessMessages, remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/de/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/de/remoteAccess.ts new file mode 100644 index 000000000..cd0fc0192 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/de/remoteAccess.ts @@ -0,0 +1,53 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "Remote-Übergabe", + "remoteAccess.title": "Remote mit CodeNomad verbinden", + "remoteAccess.subtitle": "Verwenden Sie die folgenden Adressen, um CodeNomad von einem anderen Gerät aus zu öffnen.", + "remoteAccess.close": "Remote-Zugriff schließen", + "remoteAccess.refresh": "Aktualisieren", + + "remoteAccess.sections.listeningMode.label": "Abhörmodus (Listening mode)", + "remoteAccess.sections.listeningMode.help": "Remote-Übergaben erlauben oder einschränken, indem an alle Schnittstellen oder nur an localhost gebunden wird.", + "remoteAccess.toggle.on": "An", + "remoteAccess.toggle.off": "Aus", + "remoteAccess.toggle.title": "Verbindungen von anderen IPs zulassen", + "remoteAccess.toggle.caption.all": "Bindung an 0.0.0.0", + "remoteAccess.toggle.caption.local": "Bindung an 127.0.0.1", + "remoteAccess.toggle.note": "Das Ändern dieser Einstellung erfordert einen Neustart und stoppt vorübergehend alle aktiven Instanzen. Teilen Sie die unten stehenden Adressen nach dem Neustart des Servers.", + "remoteAccess.listeningMode.restartConfirm.message": "Neustart zum Übernehmen des Abhörmodus? Dies stoppt alle laufenden Instanzen.", + "remoteAccess.listeningMode.restartConfirm.title.all": "Für andere Geräte öffnen", + "remoteAccess.listeningMode.restartConfirm.title.local": "Auf dieses Gerät beschränken", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Jetzt neu starten", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Abbrechen", + "remoteAccess.restart.errorManual": "Automatischer Neustart nicht möglich. Bitte starten Sie die App manuell neu.", + + "remoteAccess.sections.serverPassword.label": "Server-Passwort", + "remoteAccess.sections.serverPassword.help": "Remote-Übergaben erfordern ein Passwort. Legen Sie ein Passwort fest, um Anmeldungen von anderen Geräten zu ermöglichen.", + "remoteAccess.authStatus.unavailable": "Authentifizierungsstatus nicht verfügbar.", + "remoteAccess.username": "Benutzername: {username}", + "remoteAccess.password.status.set": "Ein Passwort für den Remote-Zugriff ist festgelegt.", + "remoteAccess.password.status.unset": "Noch kein Passwort festgelegt. Legen Sie eines fest, um Remote-Anmeldungen zu ermöglichen.", + "remoteAccess.password.actions.cancel": "Abbrechen", + "remoteAccess.password.actions.change": "Passwort ändern", + "remoteAccess.password.actions.set": "Passwort festlegen", + "remoteAccess.password.form.newPassword": "Neues Passwort", + "remoteAccess.password.form.confirmPassword": "Passwort bestätigen", + "remoteAccess.password.form.placeholder": "Mindestens 8 Zeichen", + "remoteAccess.password.error.tooShort": "Das Passwort muss mindestens 8 Zeichen lang sein.", + "remoteAccess.password.error.mismatch": "Die Passwörter stimmen nicht überein.", + "remoteAccess.password.save.saving": "Wird gespeichert...", + "remoteAccess.password.save.label": "Passwort speichern", + + "remoteAccess.sections.addresses.label": "Erreichbare Adressen", + "remoteAccess.sections.addresses.help": "Von einem anderen Gerät aus starten oder scannen, um die Steuerung zu übernehmen.", + "remoteAccess.addresses.loading": "Adressen werden geladen...", + "remoteAccess.addresses.none": "Noch keine Adressen verfügbar.", + "remoteAccess.addresses.actions.showOther": "{count} weitere Adressen anzeigen", + "remoteAccess.addresses.actions.hideOther": "Andere Adressen ausblenden", + "remoteAccess.address.scope.network": "Netzwerk", + "remoteAccess.address.scope.loopback": "Loopback", + "remoteAccess.address.scope.internal": "Intern", + "remoteAccess.address.open": "Öffnen", + "remoteAccess.address.showQr": "QR anzeigen", + "remoteAccess.address.hideQr": "QR ausblenden", + "remoteAccess.address.qrAlt": "QR für {url}", +} as const diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 6b9011a2b..54150ef4c 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Lange Assistentenantworten anhalten", "settings.behavior.holdLongAssistantReplies.subtitle": "Automatisches Folgen beenden, wenn eine laufende Antwort über das Ansichtsfenster hinausgeht.", "settings.nav.notifications": "Benachrichtigungen", - "settings.nav.remote": "Fernsteuerung", + "settings.nav.remote": "Remote-Zugriff", "settings.nav.speech": "Sprache", "settings.nav.providers": "Anbieter", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Benachrichtigungen aktiviert", "settings.notifications.status.disabled": "Benachrichtigungen deaktiviert", "settings.notifications.status.unsupported": "Benachrichtigungen nicht unterstützt", - "settings.section.remote.title": "Fernsteuerung", - "settings.section.remote.subtitle": "Sitzungen dieses Geräts sicher über das ausgehende Relay auf einem anderen Gerät fortsetzen.", + "settings.section.remote.title": "Remote-Zugriff", + "settings.section.remote.subtitle": "Überprüfen Sie, wie dieser Server in Ihrem Netzwerk freigegeben ist, und sichern Sie die Zugangsdaten.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Wählen Sie die OpenCode-Binärdatei und Umgebung für neue Instanzen.", "settings.opencode.runtime.title": "Laufzeit", diff --git a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts index 7b184191c..282d2b25f 100644 --- a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts @@ -41,6 +41,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "Repository URL and destination folder are required.", "folderSelection.actions.title": "Open Folder or Connect Server", "folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server", + "folderSelection.actions.connectButton": "Connect CodeNomad Server", "folderSelection.advancedSettings": "Advanced Settings", "folderSelection.opencode": "OpenCode", @@ -62,6 +63,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Select workspace to start coding.", "folderSelection.tabs.local": "Local Folders", + "folderSelection.tabs.servers": "Servers", + "folderSelection.servers.title": "Saved Servers", + "folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window", + "folderSelection.servers.count": "{count} Servers", + "folderSelection.servers.empty.title": "No Saved Servers", + "folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device", + "folderSelection.servers.connectTitle": "Connect to Server", + "folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window", + "folderSelection.servers.connectButton": "Connect to Server", + "folderSelection.servers.remove": "Remove saved server", + "folderSelection.servers.skipTls": "Self-signed TLS", + "folderSelection.servers.errorTitle": "Remote Connection Failed", + "folderSelection.servers.dialog.title": "Connect to Server", + "folderSelection.servers.dialog.description": "Add a remote CodeNomad server and optionally open it right away.", + "folderSelection.servers.dialog.name": "Server name", + "folderSelection.servers.dialog.namePlaceholder": "Production Server", + "folderSelection.servers.dialog.url": "Server URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Skip TLS verification for self-signed certificates.", + "folderSelection.servers.dialog.cancel": "Cancel", + "folderSelection.servers.dialog.save": "Save", + "folderSelection.servers.dialog.connect": "Connect", + "folderSelection.servers.dialog.connecting": "Connecting...", + "folderSelection.servers.dialog.errorRequired": "Server name and URL are required.", + "folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.", + "folderSelection.servers.certificateInstall.title": "Install Local Certificate", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.", + "folderSelection.servers.certificateInstall.confirmLabel": "Continue", + "folderSelection.servers.certificateInstall.cancelLabel": "Cancel", + "folderSelection.servers.certificateInstall.cancelled": "CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows.", "folderSelection.sidecars.button": "Open SideCar", "projectRenameDialog.title": "Rename workspace", diff --git a/packages/ui/src/lib/i18n/messages/en/index.ts b/packages/ui/src/lib/i18n/messages/en/index.ts index 6b4e3e6a8..ca45d199d 100644 --- a/packages/ui/src/lib/i18n/messages/en/index.ts +++ b/packages/ui/src/lib/i18n/messages/en/index.ts @@ -9,6 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -31,6 +32,7 @@ export const enMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, + remoteAccessMessages, remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts new file mode 100644 index 000000000..cad9f855d --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts @@ -0,0 +1,53 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "Remote handover", + "remoteAccess.title": "Connect to CodeNomad remotely", + "remoteAccess.subtitle": "Use the addresses below to open CodeNomad from another device.", + "remoteAccess.close": "Close remote access", + "remoteAccess.refresh": "Refresh", + + "remoteAccess.sections.listeningMode.label": "Listening mode", + "remoteAccess.sections.listeningMode.help": "Allow or limit remote handovers by binding to all interfaces or just localhost.", + "remoteAccess.toggle.on": "On", + "remoteAccess.toggle.off": "Off", + "remoteAccess.toggle.title": "Allow connections from other IPs", + "remoteAccess.toggle.caption.all": "Binding to 0.0.0.0", + "remoteAccess.toggle.caption.local": "Binding to 127.0.0.1", + "remoteAccess.toggle.note": "Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the server restarts.", + "remoteAccess.listeningMode.restartConfirm.message": "Restart to apply listening mode? This will stop all running instances.", + "remoteAccess.listeningMode.restartConfirm.title.all": "Open to other devices", + "remoteAccess.listeningMode.restartConfirm.title.local": "Limit to this device", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Restart now", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Cancel", + "remoteAccess.restart.errorManual": "Unable to restart automatically. Please restart the app to apply the change.", + + "remoteAccess.sections.serverPassword.label": "Server password", + "remoteAccess.sections.serverPassword.help": "Remote handovers require a password. Set a memorable one to enable logins from other devices.", + "remoteAccess.authStatus.unavailable": "Authentication status unavailable.", + "remoteAccess.username": "Username: {username}", + "remoteAccess.password.status.set": "A password is set for remote access.", + "remoteAccess.password.status.unset": "No memorable password is set yet. Set one to allow remote handover logins.", + "remoteAccess.password.actions.cancel": "Cancel", + "remoteAccess.password.actions.change": "Change password", + "remoteAccess.password.actions.set": "Set password", + "remoteAccess.password.form.newPassword": "New password", + "remoteAccess.password.form.confirmPassword": "Confirm password", + "remoteAccess.password.form.placeholder": "At least 8 characters", + "remoteAccess.password.error.tooShort": "Password must be at least 8 characters.", + "remoteAccess.password.error.mismatch": "Passwords do not match.", + "remoteAccess.password.save.saving": "Saving…", + "remoteAccess.password.save.label": "Save password", + + "remoteAccess.sections.addresses.label": "Reachable addresses", + "remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.", + "remoteAccess.addresses.loading": "Loading addresses…", + "remoteAccess.addresses.none": "No addresses available yet.", + "remoteAccess.addresses.actions.showOther": "Show {count} other addresses", + "remoteAccess.addresses.actions.hideOther": "Hide other addresses", + "remoteAccess.address.scope.network": "Network", + "remoteAccess.address.scope.loopback": "Loopback", + "remoteAccess.address.scope.internal": "Internal", + "remoteAccess.address.open": "Open", + "remoteAccess.address.showQr": "Show QR", + "remoteAccess.address.hideQr": "Hide QR", + "remoteAccess.address.qrAlt": "QR for {url}", +} as const diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 111e0a6d8..e5c2063e7 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Hold long assistant replies", "settings.behavior.holdLongAssistantReplies.subtitle": "Stop following automatically when a streaming reply grows beyond the viewport.", "settings.nav.notifications": "Notifications", - "settings.nav.remote": "Remote Control", + "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", "settings.nav.providers": "Providers", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Notifications enabled", "settings.notifications.status.disabled": "Notifications disabled", "settings.notifications.status.unsupported": "Notifications unsupported", - "settings.section.remote.title": "Remote Control", - "settings.section.remote.subtitle": "Securely continue this device's sessions from another device through the outbound relay.", + "settings.section.remote.title": "Remote Access", + "settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", diff --git a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts index d77ec086c..65d228b53 100644 --- a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts @@ -41,6 +41,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "La URL del repositorio y la carpeta de destino son obligatorias.", "folderSelection.actions.title": "Abrir carpeta o conectar servidor", "folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad", + "folderSelection.actions.connectButton": "Conectar servidor CodeNomad", "folderSelection.advancedSettings": "Configuración avanzada", "folderSelection.opencode": "OpenCode", @@ -62,6 +63,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.", "folderSelection.tabs.local": "Carpetas locales", + "folderSelection.tabs.servers": "Servidores", + "folderSelection.servers.title": "Servidores guardados", + "folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva", + "folderSelection.servers.count": "{count} servidores", + "folderSelection.servers.empty.title": "No hay servidores guardados", + "folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo", + "folderSelection.servers.connectTitle": "Conectar a un servidor", + "folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva", + "folderSelection.servers.connectButton": "Conectar a un servidor", + "folderSelection.servers.remove": "Eliminar servidor guardado", + "folderSelection.servers.skipTls": "TLS autofirmado", + "folderSelection.servers.errorTitle": "Falló la conexión remota", + "folderSelection.servers.dialog.title": "Conectar a un servidor", + "folderSelection.servers.dialog.description": "Añade un servidor remoto de CodeNomad y ábrelo ahora si quieres.", + "folderSelection.servers.dialog.name": "Nombre del servidor", + "folderSelection.servers.dialog.namePlaceholder": "Servidor de producción", + "folderSelection.servers.dialog.url": "URL del servidor", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Omitir la verificación TLS para certificados autofirmados.", + "folderSelection.servers.dialog.cancel": "Cancelar", + "folderSelection.servers.dialog.save": "Guardar", + "folderSelection.servers.dialog.connect": "Conectar", + "folderSelection.servers.dialog.connecting": "Conectando...", + "folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.", + "folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.", + "folderSelection.servers.certificateInstall.title": "Instalar certificado local", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el tráfico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado después de esto.", + "folderSelection.servers.certificateInstall.confirmLabel": "Continuar", + "folderSelection.servers.certificateInstall.cancelLabel": "Cancelar", + "folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.", "folderSelection.sidecars.button": "Abrir SideCar", "projectRenameDialog.title": "Renombrar workspace", diff --git a/packages/ui/src/lib/i18n/messages/es/index.ts b/packages/ui/src/lib/i18n/messages/es/index.ts index 7d97c2ee2..ebe445a99 100644 --- a/packages/ui/src/lib/i18n/messages/es/index.ts +++ b/packages/ui/src/lib/i18n/messages/es/index.ts @@ -9,6 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -31,6 +32,7 @@ export const esMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, + remoteAccessMessages, remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts new file mode 100644 index 000000000..f372d60c4 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts @@ -0,0 +1,53 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "Transferencia remota", + "remoteAccess.title": "Conectar a CodeNomad de forma remota", + "remoteAccess.subtitle": "Usa las direcciones de abajo para abrir CodeNomad desde otro dispositivo.", + "remoteAccess.close": "Cerrar acceso remoto", + "remoteAccess.refresh": "Actualizar", + + "remoteAccess.sections.listeningMode.label": "Modo de escucha", + "remoteAccess.sections.listeningMode.help": "Permite o limita las transferencias remotas vinculando a todas las interfaces o solo a localhost.", + "remoteAccess.toggle.on": "Activado", + "remoteAccess.toggle.off": "Desactivado", + "remoteAccess.toggle.title": "Permitir conexiones desde otras IP", + "remoteAccess.toggle.caption.all": "Vinculado a 0.0.0.0", + "remoteAccess.toggle.caption.local": "Vinculado a 127.0.0.1", + "remoteAccess.toggle.note": "Cambiar esto requiere reiniciar y detiene temporalmente todas las instancias activas. Comparte las direcciones de abajo una vez que el servidor se reinicie.", + "remoteAccess.listeningMode.restartConfirm.message": "¿Reiniciar para aplicar el modo de escucha? Esto detendrá todas las instancias en ejecución.", + "remoteAccess.listeningMode.restartConfirm.title.all": "Abrir a otros dispositivos", + "remoteAccess.listeningMode.restartConfirm.title.local": "Limitar a este dispositivo", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Reiniciar ahora", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Cancelar", + "remoteAccess.restart.errorManual": "No se pudo reiniciar automáticamente. Reinicia la app para aplicar el cambio.", + + "remoteAccess.sections.serverPassword.label": "Contraseña del servidor", + "remoteAccess.sections.serverPassword.help": "Las transferencias remotas requieren una contraseña. Define una fácil de recordar para habilitar inicios de sesión desde otros dispositivos.", + "remoteAccess.authStatus.unavailable": "Estado de autenticación no disponible.", + "remoteAccess.username": "Usuario: {username}", + "remoteAccess.password.status.set": "Hay una contraseña configurada para el acceso remoto.", + "remoteAccess.password.status.unset": "Aún no hay una contraseña fácil de recordar. Configura una para permitir inicios de sesión por transferencia remota.", + "remoteAccess.password.actions.cancel": "Cancelar", + "remoteAccess.password.actions.change": "Cambiar contraseña", + "remoteAccess.password.actions.set": "Configurar contraseña", + "remoteAccess.password.form.newPassword": "Nueva contraseña", + "remoteAccess.password.form.confirmPassword": "Confirmar contraseña", + "remoteAccess.password.form.placeholder": "Al menos 8 caracteres", + "remoteAccess.password.error.tooShort": "La contraseña debe tener al menos 8 caracteres.", + "remoteAccess.password.error.mismatch": "Las contraseñas no coinciden.", + "remoteAccess.password.save.saving": "Guardando…", + "remoteAccess.password.save.label": "Guardar contraseña", + + "remoteAccess.sections.addresses.label": "Direcciones accesibles", + "remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.", + "remoteAccess.addresses.loading": "Cargando direcciones…", + "remoteAccess.addresses.none": "Aún no hay direcciones disponibles.", + "remoteAccess.addresses.actions.showOther": "Mostrar {count} direcciones más", + "remoteAccess.addresses.actions.hideOther": "Ocultar otras direcciones", + "remoteAccess.address.scope.network": "Red", + "remoteAccess.address.scope.loopback": "Loopback", + "remoteAccess.address.scope.internal": "Interna", + "remoteAccess.address.open": "Abrir", + "remoteAccess.address.showQr": "Mostrar QR", + "remoteAccess.address.hideQr": "Ocultar QR", + "remoteAccess.address.qrAlt": "QR para {url}", +} as const diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 98fd65cb7..39a8fb460 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Retener respuestas largas del asistente", "settings.behavior.holdLongAssistantReplies.subtitle": "Deja de seguir automáticamente cuando una respuesta en curso supera la ventana.", "settings.nav.notifications": "Notificaciones", - "settings.nav.remote": "Control remoto", + "settings.nav.remote": "Acceso remoto", "settings.nav.speech": "Voz", "settings.nav.providers": "Proveedores", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Notificaciones activadas", "settings.notifications.status.disabled": "Notificaciones desactivadas", "settings.notifications.status.unsupported": "Notificaciones no compatibles", - "settings.section.remote.title": "Control remoto", - "settings.section.remote.subtitle": "Continúa de forma segura las sesiones de este dispositivo desde otro mediante el relé saliente.", + "settings.section.remote.title": "Acceso remoto", + "settings.section.remote.subtitle": "Revisa cómo se expone este servidor en tu red y protege las credenciales de acceso.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Elige el binario de OpenCode y el entorno usados para nuevas instancias.", "settings.opencode.runtime.title": "Runtime", diff --git a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts index 0ef97b0ed..3190c23c0 100644 --- a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts @@ -41,6 +41,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "L'URL du depot et le dossier de destination sont requis.", "folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur", "folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad", + "folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad", "folderSelection.advancedSettings": "Paramètres avancés", "folderSelection.opencode": "OpenCode", @@ -62,6 +63,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.", "folderSelection.tabs.local": "Dossiers locaux", + "folderSelection.tabs.servers": "Serveurs", + "folderSelection.servers.title": "Serveurs enregistrés", + "folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre", + "folderSelection.servers.count": "{count} serveurs", + "folderSelection.servers.empty.title": "Aucun serveur enregistré", + "folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil", + "folderSelection.servers.connectTitle": "Se connecter à un serveur", + "folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre", + "folderSelection.servers.connectButton": "Se connecter à un serveur", + "folderSelection.servers.remove": "Supprimer le serveur enregistré", + "folderSelection.servers.skipTls": "TLS auto-signé", + "folderSelection.servers.errorTitle": "Échec de la connexion distante", + "folderSelection.servers.dialog.title": "Se connecter à un serveur", + "folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.", + "folderSelection.servers.dialog.name": "Nom du serveur", + "folderSelection.servers.dialog.namePlaceholder": "Serveur de production", + "folderSelection.servers.dialog.url": "URL du serveur", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.", + "folderSelection.servers.dialog.cancel": "Annuler", + "folderSelection.servers.dialog.save": "Enregistrer", + "folderSelection.servers.dialog.connect": "Se connecter", + "folderSelection.servers.dialog.connecting": "Connexion...", + "folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.", + "folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.", + "folderSelection.servers.certificateInstall.title": "Installer le certificat local", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.", + "folderSelection.servers.certificateInstall.confirmLabel": "Continuer", + "folderSelection.servers.certificateInstall.cancelLabel": "Annuler", + "folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.", "folderSelection.sidecars.button": "Ouvrir SideCar", "projectRenameDialog.title": "Renommer l'espace de travail", diff --git a/packages/ui/src/lib/i18n/messages/fr/index.ts b/packages/ui/src/lib/i18n/messages/fr/index.ts index efa409c7c..682354322 100644 --- a/packages/ui/src/lib/i18n/messages/fr/index.ts +++ b/packages/ui/src/lib/i18n/messages/fr/index.ts @@ -9,6 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -31,6 +32,7 @@ export const frMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, + remoteAccessMessages, remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts new file mode 100644 index 000000000..3b6c17add --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts @@ -0,0 +1,53 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "Passation à distance", + "remoteAccess.title": "Se connecter à CodeNomad à distance", + "remoteAccess.subtitle": "Utilisez les adresses ci-dessous pour ouvrir CodeNomad depuis un autre appareil.", + "remoteAccess.close": "Fermer l'accès à distance", + "remoteAccess.refresh": "Rafraîchir", + + "remoteAccess.sections.listeningMode.label": "Mode d'écoute", + "remoteAccess.sections.listeningMode.help": "Autorisez ou limitez les passations à distance en écoutant sur toutes les interfaces ou uniquement sur localhost.", + "remoteAccess.toggle.on": "Activé", + "remoteAccess.toggle.off": "Désactivé", + "remoteAccess.toggle.title": "Autoriser les connexions depuis d'autres IP", + "remoteAccess.toggle.caption.all": "Écoute sur 0.0.0.0", + "remoteAccess.toggle.caption.local": "Écoute sur 127.0.0.1", + "remoteAccess.toggle.note": "Modifier ceci nécessite un redémarrage et stoppe temporairement toutes les instances actives. Partagez les adresses ci-dessous une fois le serveur redémarré.", + "remoteAccess.listeningMode.restartConfirm.message": "Redémarrer pour appliquer le mode d'écoute ? Cela arrêtera toutes les instances en cours.", + "remoteAccess.listeningMode.restartConfirm.title.all": "Ouvrir aux autres appareils", + "remoteAccess.listeningMode.restartConfirm.title.local": "Limiter à cet appareil", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Redémarrer maintenant", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Annuler", + "remoteAccess.restart.errorManual": "Impossible de redémarrer automatiquement. Veuillez redémarrer l'application pour appliquer le changement.", + + "remoteAccess.sections.serverPassword.label": "Mot de passe du serveur", + "remoteAccess.sections.serverPassword.help": "Les passations à distance nécessitent un mot de passe. Définissez-en un facile à retenir pour autoriser la connexion depuis d'autres appareils.", + "remoteAccess.authStatus.unavailable": "Statut d'authentification indisponible.", + "remoteAccess.username": "Nom d'utilisateur : {username}", + "remoteAccess.password.status.set": "Un mot de passe est défini pour l'accès à distance.", + "remoteAccess.password.status.unset": "Aucun mot de passe mémorable n'est encore défini. Définissez-en un pour autoriser les connexions à distance.", + "remoteAccess.password.actions.cancel": "Annuler", + "remoteAccess.password.actions.change": "Changer le mot de passe", + "remoteAccess.password.actions.set": "Définir le mot de passe", + "remoteAccess.password.form.newPassword": "Nouveau mot de passe", + "remoteAccess.password.form.confirmPassword": "Confirmer le mot de passe", + "remoteAccess.password.form.placeholder": "Au moins 8 caractères", + "remoteAccess.password.error.tooShort": "Le mot de passe doit contenir au moins 8 caractères.", + "remoteAccess.password.error.mismatch": "Les mots de passe ne correspondent pas.", + "remoteAccess.password.save.saving": "Enregistrement…", + "remoteAccess.password.save.label": "Enregistrer le mot de passe", + + "remoteAccess.sections.addresses.label": "Adresses accessibles", + "remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.", + "remoteAccess.addresses.loading": "Chargement des adresses…", + "remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.", + "remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses", + "remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses", + "remoteAccess.address.scope.network": "Réseau", + "remoteAccess.address.scope.loopback": "Boucle locale", + "remoteAccess.address.scope.internal": "Interne", + "remoteAccess.address.open": "Ouvrir", + "remoteAccess.address.showQr": "Afficher le QR", + "remoteAccess.address.hideQr": "Masquer le QR", + "remoteAccess.address.qrAlt": "QR pour {url}", +} as const diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 86e57d229..5b48289fe 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Suspendre le suivi des longues réponses", "settings.behavior.holdLongAssistantReplies.subtitle": "Arrêter le suivi automatique lorsqu'une réponse en cours dépasse la fenêtre.", "settings.nav.notifications": "Notifications", - "settings.nav.remote": "Contrôle à distance", + "settings.nav.remote": "Accès distant", "settings.nav.speech": "Voix", "settings.nav.providers": "Fournisseurs", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Notifications activées", "settings.notifications.status.disabled": "Notifications désactivées", "settings.notifications.status.unsupported": "Notifications non prises en charge", - "settings.section.remote.title": "Contrôle à distance", - "settings.section.remote.subtitle": "Continuez les sessions de cet appareil depuis un autre appareil via le relais sortant sécurisé.", + "settings.section.remote.title": "Accès distant", + "settings.section.remote.subtitle": "Vérifiez comment ce serveur est exposé sur votre réseau et sécurisez les identifiants d'accès.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Choisissez le binaire OpenCode et l'environnement utilisés pour les nouvelles instances.", "settings.opencode.runtime.title": "Environnement d'exécution", diff --git a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts index b15e4df48..26f8dc4b8 100644 --- a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts @@ -41,6 +41,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "כתובת המאגר ותיקיית היעד הן שדות חובה.", "folderSelection.actions.title": "פתח תיקייה או התחבר לשרת", "folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad", + "folderSelection.actions.connectButton": "התחבר לשרת CodeNomad", "folderSelection.advancedSettings": "הגדרות מתקדמות", "folderSelection.opencode": "OpenCode", @@ -62,6 +63,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.", "folderSelection.tabs.local": "תיקיות מקומיות", + "folderSelection.tabs.servers": "שרתים", + "folderSelection.servers.title": "שרתים שמורים", + "folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש", + "folderSelection.servers.count": "{count} שרתים", + "folderSelection.servers.empty.title": "אין שרתים שמורים", + "folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה", + "folderSelection.servers.connectTitle": "התחבר לשרת", + "folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש", + "folderSelection.servers.connectButton": "התחבר לשרת", + "folderSelection.servers.remove": "הסר שרת שמור", + "folderSelection.servers.skipTls": "TLS בחתימה עצמית", + "folderSelection.servers.errorTitle": "החיבור המרוחק נכשל", + "folderSelection.servers.dialog.title": "התחבר לשרת", + "folderSelection.servers.dialog.description": "הוסף שרת CodeNomad מרוחק ופתח אותו מיד אם תרצה.", + "folderSelection.servers.dialog.name": "שם השרת", + "folderSelection.servers.dialog.namePlaceholder": "שרת ייצור", + "folderSelection.servers.dialog.url": "כתובת השרת", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "דלג על אימות TLS עבור תעודות בחתימה עצמית.", + "folderSelection.servers.dialog.cancel": "ביטול", + "folderSelection.servers.dialog.save": "שמור", + "folderSelection.servers.dialog.connect": "התחבר", + "folderSelection.servers.dialog.connecting": "מתחבר...", + "folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.", + "folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.", + "folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.", + "folderSelection.servers.certificateInstall.confirmLabel": "המשך", + "folderSelection.servers.certificateInstall.cancelLabel": "ביטול", + "folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.", "folderSelection.sidecars.button": "פתח SideCar", "projectRenameDialog.title": "שנה שם סביבת עבודה", diff --git a/packages/ui/src/lib/i18n/messages/he/index.ts b/packages/ui/src/lib/i18n/messages/he/index.ts index c946829a8..ed799d3e8 100644 --- a/packages/ui/src/lib/i18n/messages/he/index.ts +++ b/packages/ui/src/lib/i18n/messages/he/index.ts @@ -9,6 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -31,6 +32,7 @@ export const heMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, + remoteAccessMessages, remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts new file mode 100644 index 000000000..dc026c46d --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts @@ -0,0 +1,53 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "גישה מרוחקת", + "remoteAccess.title": "התחבר ל-CodeNomad מרחוק", + "remoteAccess.subtitle": "השתמש בכתובות למטה כדי לפתוח את CodeNomad ממכשיר אחר.", + "remoteAccess.close": "סגור גישה מרוחקת", + "remoteAccess.refresh": "רענן", + + "remoteAccess.sections.listeningMode.label": "מצב האזנה", + "remoteAccess.sections.listeningMode.help": "אפשר או הגבל גישה מרוחקת על ידי קישור לכל הממשקים או רק ל-localhost.", + "remoteAccess.toggle.on": "פועל", + "remoteAccess.toggle.off": "כבוי", + "remoteAccess.toggle.title": "אפשר חיבורים מכתובות IP אחרות", + "remoteAccess.toggle.caption.all": "מקושר ל-0.0.0.0", + "remoteAccess.toggle.caption.local": "מקושר ל-127.0.0.1", + "remoteAccess.toggle.note": "שינוי זה דורש הפעלה מחדש ועוצר זמנית את כל המופעים הפעילים. שתף את הכתובות למטה לאחר שהשרת יופעל מחדש.", + "remoteAccess.listeningMode.restartConfirm.message": "להפעיל מחדש כדי להחיל מצב האזנה? פעולה זו תעצור את כל המופעים הפעילים.", + "remoteAccess.listeningMode.restartConfirm.title.all": "פתוח למכשירים אחרים", + "remoteAccess.listeningMode.restartConfirm.title.local": "מוגבל למכשיר זה", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "הפעל מחדש עכשיו", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "ביטול", + "remoteAccess.restart.errorManual": "לא ניתן להפעיל מחדש אוטומטית. אנא הפעל מחדש את האפליקציה כדי להחיל את השינוי.", + + "remoteAccess.sections.serverPassword.label": "סיסמת שרת", + "remoteAccess.sections.serverPassword.help": "גישה מרוחקת דורשת סיסמה. הגדר סיסמה קלה לזכירה כדי לאפשר כניסות ממכשירים אחרים.", + "remoteAccess.authStatus.unavailable": "סטטוס האימות אינו זמין.", + "remoteAccess.username": "שם משתמש: {username}", + "remoteAccess.password.status.set": "סיסמה מוגדרת לגישה מרוחקת.", + "remoteAccess.password.status.unset": "לא הוגדרה סיסמה קלה לזכירה. הגדר סיסמה כדי לאפשר כניסות גישה מרוחקת.", + "remoteAccess.password.actions.cancel": "ביטול", + "remoteAccess.password.actions.change": "שנה סיסמה", + "remoteAccess.password.actions.set": "הגדר סיסמה", + "remoteAccess.password.form.newPassword": "סיסמה חדשה", + "remoteAccess.password.form.confirmPassword": "אשר סיסמה", + "remoteAccess.password.form.placeholder": "לפחות 8 תווים", + "remoteAccess.password.error.tooShort": "הסיסמה חייבת להכיל לפחות 8 תווים.", + "remoteAccess.password.error.mismatch": "הסיסמאות אינן תואמות.", + "remoteAccess.password.save.saving": "שומר…", + "remoteAccess.password.save.label": "שמור סיסמה", + + "remoteAccess.sections.addresses.label": "כתובות נגישות", + "remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.", + "remoteAccess.addresses.loading": "טוען כתובות…", + "remoteAccess.addresses.none": "אין כתובות זמינות עדיין.", + "remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות", + "remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות", + "remoteAccess.address.scope.network": "רשת", + "remoteAccess.address.scope.loopback": "לולאה מקומית", + "remoteAccess.address.scope.internal": "פנימי", + "remoteAccess.address.open": "פתח", + "remoteAccess.address.showQr": "הצג QR", + "remoteAccess.address.hideQr": "הסתר QR", + "remoteAccess.address.qrAlt": "QR עבור {url}", +} as const diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index de635952e..8eafb913d 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "השהיית מעקב אחר תשובות ארוכות", "settings.behavior.holdLongAssistantReplies.subtitle": "הפסק מעקב אוטומטי כאשר תשובה זורמת חורגת מחלון התצוגה.", "settings.nav.notifications": "התראות", - "settings.nav.remote": "שליטה מרחוק", + "settings.nav.remote": "גישה מרוחקת", "settings.nav.speech": "קול", "settings.nav.providers": "ספקים", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "התראות מופעלות", "settings.notifications.status.disabled": "התראות מושבתות", "settings.notifications.status.unsupported": "התראות לא נתמכות", - "settings.section.remote.title": "שליטה מרחוק", - "settings.section.remote.subtitle": "המשך באופן מאובטח את ההפעלות של מכשיר זה ממכשיר אחר דרך הממסר היוצא.", + "settings.section.remote.title": "גישה מרוחקת", + "settings.section.remote.subtitle": "בדוק כיצד שרת זה חשוף ברשת שלך ואבטח אישורי גישה.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.", "settings.opencode.runtime.title": "סביבת ריצה", diff --git a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts index 1a61d656c..230528651 100644 --- a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts @@ -41,6 +41,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "リポジトリ URL と保存先フォルダは必須です。", "folderSelection.actions.title": "フォルダを開くかサーバーに接続", "folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します", + "folderSelection.actions.connectButton": "CodeNomad サーバーに接続", "folderSelection.advancedSettings": "詳細設定", "folderSelection.opencode": "OpenCode", @@ -62,6 +63,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。", "folderSelection.tabs.local": "ローカルフォルダ", + "folderSelection.tabs.servers": "サーバー", + "folderSelection.servers.title": "保存済みサーバー", + "folderSelection.servers.subtitle": "保存したリモート CodeNomad サーバーを新しいウィンドウで開きます", + "folderSelection.servers.count": "{count} サーバー", + "folderSelection.servers.empty.title": "保存済みサーバーはありません", + "folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーを追加してください", + "folderSelection.servers.connectTitle": "サーバーに接続", + "folderSelection.servers.connectSubtitle": "リモート CodeNomad サーバーを保存して新しいウィンドウで開きます", + "folderSelection.servers.connectButton": "サーバーに接続", + "folderSelection.servers.remove": "保存したサーバーを削除", + "folderSelection.servers.skipTls": "自己署名 TLS", + "folderSelection.servers.errorTitle": "リモート接続に失敗しました", + "folderSelection.servers.dialog.title": "サーバーに接続", + "folderSelection.servers.dialog.description": "リモート CodeNomad サーバーを追加し、必要に応じてすぐに開きます。", + "folderSelection.servers.dialog.name": "サーバー名", + "folderSelection.servers.dialog.namePlaceholder": "本番サーバー", + "folderSelection.servers.dialog.url": "サーバー URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "自己署名証明書の TLS 検証をスキップします。", + "folderSelection.servers.dialog.cancel": "キャンセル", + "folderSelection.servers.dialog.save": "保存", + "folderSelection.servers.dialog.connect": "接続", + "folderSelection.servers.dialog.connecting": "接続中...", + "folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。", + "folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。", + "folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。", + "folderSelection.servers.certificateInstall.confirmLabel": "続行", + "folderSelection.servers.certificateInstall.cancelLabel": "キャンセル", + "folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。", "folderSelection.sidecars.button": "SideCar を開く", "projectRenameDialog.title": "ワークスペース名を変更", diff --git a/packages/ui/src/lib/i18n/messages/ja/index.ts b/packages/ui/src/lib/i18n/messages/ja/index.ts index 5d676be56..f792b5e27 100644 --- a/packages/ui/src/lib/i18n/messages/ja/index.ts +++ b/packages/ui/src/lib/i18n/messages/ja/index.ts @@ -9,6 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -31,6 +32,7 @@ export const jaMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, + remoteAccessMessages, remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts new file mode 100644 index 000000000..996b481e1 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts @@ -0,0 +1,53 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "リモート引き継ぎ", + "remoteAccess.title": "CodeNomad にリモート接続", + "remoteAccess.subtitle": "別のデバイスから CodeNomad を開くには、以下のアドレスを使用してください。", + "remoteAccess.close": "リモートアクセスを閉じる", + "remoteAccess.refresh": "更新", + + "remoteAccess.sections.listeningMode.label": "リッスンモード", + "remoteAccess.sections.listeningMode.help": "全インターフェースにバインドするか localhost のみにするかで、リモート引き継ぎを許可/制限します。", + "remoteAccess.toggle.on": "オン", + "remoteAccess.toggle.off": "オフ", + "remoteAccess.toggle.title": "他の IP からの接続を許可", + "remoteAccess.toggle.caption.all": "0.0.0.0 にバインド", + "remoteAccess.toggle.caption.local": "127.0.0.1 にバインド", + "remoteAccess.toggle.note": "変更には再起動が必要で、すべての稼働中インスタンスが一時的に停止します。サーバー再起動後に以下のアドレスを共有してください。", + "remoteAccess.listeningMode.restartConfirm.message": "リッスンモードを適用するため再起動しますか?実行中のインスタンスはすべて停止します。", + "remoteAccess.listeningMode.restartConfirm.title.all": "他のデバイスに公開", + "remoteAccess.listeningMode.restartConfirm.title.local": "このデバイスに限定", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "今すぐ再起動", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "キャンセル", + "remoteAccess.restart.errorManual": "自動で再起動できませんでした。変更を適用するにはアプリを再起動してください。", + + "remoteAccess.sections.serverPassword.label": "サーバーパスワード", + "remoteAccess.sections.serverPassword.help": "リモート引き継ぎにはパスワードが必要です。覚えやすいものを設定して他のデバイスからのログインを有効にします。", + "remoteAccess.authStatus.unavailable": "認証状態を取得できません。", + "remoteAccess.username": "ユーザー名: {username}", + "remoteAccess.password.status.set": "リモートアクセス用のパスワードが設定されています。", + "remoteAccess.password.status.unset": "まだ覚えやすいパスワードが設定されていません。設定してリモート引き継ぎログインを有効にしてください。", + "remoteAccess.password.actions.cancel": "キャンセル", + "remoteAccess.password.actions.change": "パスワードを変更", + "remoteAccess.password.actions.set": "パスワードを設定", + "remoteAccess.password.form.newPassword": "新しいパスワード", + "remoteAccess.password.form.confirmPassword": "パスワードの確認", + "remoteAccess.password.form.placeholder": "8 文字以上", + "remoteAccess.password.error.tooShort": "パスワードは 8 文字以上である必要があります。", + "remoteAccess.password.error.mismatch": "パスワードが一致しません。", + "remoteAccess.password.save.saving": "保存中…", + "remoteAccess.password.save.label": "パスワードを保存", + + "remoteAccess.sections.addresses.label": "到達可能なアドレス", + "remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。", + "remoteAccess.addresses.loading": "アドレスを読み込み中…", + "remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。", + "remoteAccess.addresses.actions.showOther": "他の {count} 件のアドレスを表示", + "remoteAccess.addresses.actions.hideOther": "他のアドレスを隠す", + "remoteAccess.address.scope.network": "ネットワーク", + "remoteAccess.address.scope.loopback": "ループバック", + "remoteAccess.address.scope.internal": "内部", + "remoteAccess.address.open": "開く", + "remoteAccess.address.showQr": "QR を表示", + "remoteAccess.address.hideQr": "QR を非表示", + "remoteAccess.address.qrAlt": "{url} の QR", +} as const diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index f6ec59126..ffee6a953 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "長いアシスタント応答を保持", "settings.behavior.holdLongAssistantReplies.subtitle": "ストリーミング応答が画面を超えたときに自動追従を停止します。", "settings.nav.notifications": "通知", - "settings.nav.remote": "リモートコントロール", + "settings.nav.remote": "リモートアクセス", "settings.nav.speech": "音声", "settings.nav.providers": "プロバイダー", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "通知は有効です", "settings.notifications.status.disabled": "通知は無効です", "settings.notifications.status.unsupported": "通知は未対応です", - "settings.section.remote.title": "リモートコントロール", - "settings.section.remote.subtitle": "安全な送信専用リレーを介して、別の端末からこの端末のセッションを続けます。", + "settings.section.remote.title": "リモートアクセス", + "settings.section.remote.subtitle": "このサーバーがネットワーク上でどのように公開されているかと、アクセス認証情報を確認します。", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "新しいインスタンスで使う OpenCode バイナリと環境を選択します。", "settings.opencode.runtime.title": "ランタイム", diff --git a/packages/ui/src/lib/i18n/messages/ne/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ne/folderSelection.ts index a6032582f..31f46c316 100644 --- a/packages/ui/src/lib/i18n/messages/ne/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ne/folderSelection.ts @@ -41,6 +41,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "रिपोजिटरी URL र गन्तव्य फोल्डर आवश्यक छ।", "folderSelection.actions.title": "फोल्डर खोल्नुहोस् वा सर्भर जडान गर्नुहोस्", "folderSelection.actions.subtitle": "स्थानीय फोल्डर खोल्नुहोस् वा CodeNomad सर्भरमा जडान गर्नुहोस्", + "folderSelection.actions.connectButton": "CodeNomad सर्भर जडान गर्नुहोस्", "folderSelection.advancedSettings": "उन्नत सेटिङहरू", "folderSelection.opencode": "OpenCode", @@ -62,6 +63,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "कोडिङ सुरु गर्न कार्यस्थान चयन गर्नुहोस्।", "folderSelection.tabs.local": "स्थानीय फोल्डरहरू", + "folderSelection.tabs.servers": "सर्भरहरू", + "folderSelection.servers.title": "बचत गरिएका सर्भरहरू", + "folderSelection.servers.subtitle": "नयाँ विन्डोमा सुरक्षित गरिएको रिमोट CodeNomad सर्भर खोल्नुहोस्", + "folderSelection.servers.count": "{count} सर्भरहरू", + "folderSelection.servers.empty.title": "कुनै बचत गरिएका सर्भरहरू छैनन्", + "folderSelection.servers.empty.description": "यो उपकरणबाट छिटो पुन: जडान गर्न रिमोट सर्भर थप्नुहोस्", + "folderSelection.servers.connectTitle": "सर्भरमा जडान गर्नुहोस्", + "folderSelection.servers.connectSubtitle": "रिमोट CodeNomad सर्भर बचत गर्नुहोस् र यसलाई नयाँ विन्डोमा खोल्नुहोस्", + "folderSelection.servers.connectButton": "सर्भरमा जडान गर्नुहोस्", + "folderSelection.servers.remove": "बचत गरिएको सर्भर हटाउनुहोस्", + "folderSelection.servers.skipTls": "स्व-हस्ताक्षरित TLS", + "folderSelection.servers.errorTitle": "रिमोट जडान असफल भयो", + "folderSelection.servers.dialog.title": "सर्भरमा जडान गर्नुहोस्", + "folderSelection.servers.dialog.description": "रिमोट CodeNomad सर्भर थप्नुहोस् र वैकल्पिक रूपमा यसलाई तुरुन्तै खोल्नुहोस्।", + "folderSelection.servers.dialog.name": "सर्भरको नाम", + "folderSelection.servers.dialog.namePlaceholder": "उत्पादन सर्भर", + "folderSelection.servers.dialog.url": "सर्भर URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "स्व-हस्ताक्षरित प्रमाणपत्रहरूको लागि TLS प्रमाणीकरण छोड्नुहोस्।", + "folderSelection.servers.dialog.cancel": "रद्द गर्नुहोस्", + "folderSelection.servers.dialog.save": "बचत गर्नुहोस्", + "folderSelection.servers.dialog.connect": "जडान गर्नुहोस्", + "folderSelection.servers.dialog.connecting": "जडान गर्दै...", + "folderSelection.servers.dialog.errorRequired": "सर्भरको नाम र URL आवश्यक छ।", + "folderSelection.servers.dialog.errorConnect": "रिमोट सर्भरमा जडान गर्न सकिएन।", + "folderSelection.servers.certificateInstall.title": "स्थानीय प्रमाणपत्र स्थापना गर्नुहोस्", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad लाई स्व-हस्ताक्षरित HTTPS रिमोट विन्डोहरू खोल्न स्थानीय प्रमाणपत्र स्थापना गर्न आवश्यक छ। यो प्रमाणपत्र तपाईंको मेसिनमा स्थानीय डेस्कटप प्रोक्सी ट्राफिकको लागि मात्र प्रयोग गरिन्छ। तपाईंको अपरेटिङ सिस्टमले यसपछि दोस्रो प्रमाणपत्र प्रम्प्ट देखाउन सक्छ।", + "folderSelection.servers.certificateInstall.confirmLabel": "जारी राख्नुहोस्", + "folderSelection.servers.certificateInstall.cancelLabel": "रद्द गर्नुहोस्", + "folderSelection.servers.certificateInstall.cancelled": "CodeNomad लाई स्व-हस्ताक्षरित HTTPS रिमोट विन्डोहरू खोल्न सक्नु अघि स्थानीय प्रमाणपत्र विश्वास गरिनु पर्छ।", "folderSelection.sidecars.button": "SideCar खोल्नुहोस्", "projectRenameDialog.title": "कार्यस्थान पुन: नामकरण गर्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ne/index.ts b/packages/ui/src/lib/i18n/messages/ne/index.ts index 78d7f8285..18593389d 100644 --- a/packages/ui/src/lib/i18n/messages/ne/index.ts +++ b/packages/ui/src/lib/i18n/messages/ne/index.ts @@ -9,6 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -31,6 +32,7 @@ export const neMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, + remoteAccessMessages, remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/ne/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ne/remoteAccess.ts new file mode 100644 index 000000000..297674566 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ne/remoteAccess.ts @@ -0,0 +1,53 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "रिमोट ह्यान्डोभर", + "remoteAccess.title": "CodeNomad सँग टाढैबाट (Remotely) जडान गर्नुहोस्", + "remoteAccess.subtitle": "अर्को उपकरणबाट CodeNomad खोल्न तलका ठेगानाहरू प्रयोग गर्नुहोस्।", + "remoteAccess.close": "रिमोट पहुँच बन्द गर्नुहोस्", + "remoteAccess.refresh": "रिफ्रेस", + + "remoteAccess.sections.listeningMode.label": "लिस्निङ मोड (Listening mode)", + "remoteAccess.sections.listeningMode.help": "सबै इन्टरफेस वा केवल localhost मा बाइन्ड गरेर रिमोट ह्यान्डोभरलाई अनुमति दिनुहोस् वा सीमित गर्नुहोस्।", + "remoteAccess.toggle.on": "अन", + "remoteAccess.toggle.off": "अफ", + "remoteAccess.toggle.title": "अन्य IP हरूबाट जडान अनुमति दिनुहोस्", + "remoteAccess.toggle.caption.all": "0.0.0.0 मा बाइन्ड गर्दै", + "remoteAccess.toggle.caption.local": "127.0.0.1 मा बाइन्ड गर्दै", + "remoteAccess.toggle.note": "यसलाई परिवर्तन गर्दा रिस्टार्ट आवश्यक पर्छ र अस्थायी रूपमा सबै सक्रिय उदाहरणहरू रोकिन्छ। सर्भर रिस्टार्ट भएपछि तलका ठेगानाहरू साझा गर्नुहोस्।", + "remoteAccess.listeningMode.restartConfirm.message": "लिस्निङ मोड लागू गर्न रिस्टार्ट गर्ने? यसले चलिरहेका सबै उदाहरणहरू रोक्नेछ।", + "remoteAccess.listeningMode.restartConfirm.title.all": "अन्य उपकरणहरूको लागि खोल्नुहोस्", + "remoteAccess.listeningMode.restartConfirm.title.local": "यस उपकरणमा सीमित गर्नुहोस्", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "अहिले रिस्टार्ट गर्नुहोस्", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "रद्द गर्नुहोस्", + "remoteAccess.restart.errorManual": "स्वत: रिस्टार्ट गर्न असमर्थ। कृपया परिवर्तन लागू गर्न एप रिस्टार्ट गर्नुहोस्।", + + "remoteAccess.sections.serverPassword.label": "सर्भर पासवर्ड", + "remoteAccess.sections.serverPassword.help": "रिमोट ह्यान्डोभरका लागि पासवर्ड आवश्यक पर्छ। अन्य उपकरणहरूबाट लगइन सक्षम गर्न एउटा पासवर्ड सेट गर्नुहोस्।", + "remoteAccess.authStatus.unavailable": "प्रमाणीकरण स्थिति उपलब्ध छैन।", + "remoteAccess.username": "प्रयोगकर्ता नाम: {username}", + "remoteAccess.password.status.set": "रिमोट पहुँचको लागि पासवर्ड सेट गरिएको छ।", + "remoteAccess.password.status.unset": "अझै कुनै पासवर्ड सेट गरिएको छैन। रिमोट लगइन अनुमति दिन एउटा सेट गर्नुहोस्।", + "remoteAccess.password.actions.cancel": "रद्द गर्नुहोस्", + "remoteAccess.password.actions.change": "पासवर्ड परिवर्तन गर्नुहोस्", + "remoteAccess.password.actions.set": "पासवर्ड सेट गर्नुहोस्", + "remoteAccess.password.form.newPassword": "नयाँ पासवर्ड", + "remoteAccess.password.form.confirmPassword": "पासवर्ड पुष्टि गर्नुहोस्", + "remoteAccess.password.form.placeholder": "कम्तिमा ८ अक्षरहरू", + "remoteAccess.password.error.tooShort": "पासवर्ड कम्तिमा ८ अक्षरको हुनुपर्छ।", + "remoteAccess.password.error.mismatch": "पासवर्डहरू मेल खाएनन्।", + "remoteAccess.password.save.saving": "बचत गर्दै...", + "remoteAccess.password.save.label": "पासवर्ड बचत गर्नुहोस्", + + "remoteAccess.sections.addresses.label": "पहुँचयोग्य ठेगानाहरू", + "remoteAccess.sections.addresses.help": "नियन्त्रण लिन अर्को मेसिनबाट सुरु गर्नुहोस् वा स्क्यान गर्नुहोस्।", + "remoteAccess.addresses.loading": "ठेगानाहरू लोड गर्दै...", + "remoteAccess.addresses.none": "अझै कुनै ठेगानाहरू उपलब्ध छैनन्।", + "remoteAccess.addresses.actions.showOther": "अन्य {count} ठेगानाहरू देखाउनुहोस्", + "remoteAccess.addresses.actions.hideOther": "अन्य ठेगानाहरू लुकाउनुहोस्", + "remoteAccess.address.scope.network": "नेटवर्क", + "remoteAccess.address.scope.loopback": "लूपब्याक", + "remoteAccess.address.scope.internal": "आन्तरिक", + "remoteAccess.address.open": "खोल्नुहोस्", + "remoteAccess.address.showQr": "QR देखाउनुहोस्", + "remoteAccess.address.hideQr": "QR लुकाउनुहोस्", + "remoteAccess.address.qrAlt": "{url} को लागि QR", +} as const diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 6025af691..3ab2a000d 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "लामो सहायक जवाफहरू रोक्नुहोस्", "settings.behavior.holdLongAssistantReplies.subtitle": "स्ट्रिमिङ जवाफ दृश्यभन्दा बाहिर जाँदा स्वचालित पछ्याइ रोक्नुहोस्।", "settings.nav.notifications": "सूचनाहरू", - "settings.nav.remote": "रिमोट कन्ट्रोल", + "settings.nav.remote": "रिमोट पहुँच", "settings.nav.speech": "वाचन (Speech)", "settings.nav.providers": "प्रदायकहरू", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "सूचनाहरू सक्षम गरियो", "settings.notifications.status.disabled": "सूचनाहरू अक्षम गरियो", "settings.notifications.status.unsupported": "सूचनाहरू असमर्थित", - "settings.section.remote.title": "रिमोट कन्ट्रोल", - "settings.section.remote.subtitle": "सुरक्षित आउटबाउन्ड रिले मार्फत अर्को उपकरणबाट यस उपकरणका सत्रहरू जारी राख्नुहोस्।", + "settings.section.remote.title": "रिमोट पहुँच", + "settings.section.remote.subtitle": "यो सर्भर तपाईंको नेटवर्कमा कसरी देखिन्छ र पहुँच प्रमाणहरू समीक्षा गर्नुहोस्।", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "नयाँ उदाहरणहरूको लागि OpenCode बाइनरी र वातावरण छनौट गर्नुहोस्।", "settings.opencode.runtime.title": "रनटाइम (Runtime)", diff --git a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts index a05acb8e5..bae2807ac 100644 --- a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts @@ -41,6 +41,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "URL репозитория и папка назначения обязательны.", "folderSelection.actions.title": "Открыть папку или подключить сервер", "folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad", + "folderSelection.actions.connectButton": "Подключить сервер CodeNomad", "folderSelection.advancedSettings": "Расширенные настройки", "folderSelection.opencode": "OpenCode", @@ -62,6 +63,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.", "folderSelection.tabs.local": "Локальные папки", + "folderSelection.tabs.servers": "Серверы", + "folderSelection.servers.title": "Сохраненные серверы", + "folderSelection.servers.subtitle": "Откройте сохраненный удаленный сервер CodeNomad в новом окне", + "folderSelection.servers.count": "{count} серверов", + "folderSelection.servers.empty.title": "Нет сохраненных серверов", + "folderSelection.servers.empty.description": "Добавьте удаленный сервер, чтобы быстро подключаться к нему с этого устройства", + "folderSelection.servers.connectTitle": "Подключиться к серверу", + "folderSelection.servers.connectSubtitle": "Сохраните удаленный сервер CodeNomad и откройте его в новом окне", + "folderSelection.servers.connectButton": "Подключиться к серверу", + "folderSelection.servers.remove": "Удалить сохраненный сервер", + "folderSelection.servers.skipTls": "Самоподписанный TLS", + "folderSelection.servers.errorTitle": "Ошибка удаленного подключения", + "folderSelection.servers.dialog.title": "Подключиться к серверу", + "folderSelection.servers.dialog.description": "Добавьте удаленный сервер CodeNomad и при желании сразу откройте его.", + "folderSelection.servers.dialog.name": "Имя сервера", + "folderSelection.servers.dialog.namePlaceholder": "Продакшн сервер", + "folderSelection.servers.dialog.url": "URL сервера", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Пропустить проверку TLS для самоподписанных сертификатов.", + "folderSelection.servers.dialog.cancel": "Отмена", + "folderSelection.servers.dialog.save": "Сохранить", + "folderSelection.servers.dialog.connect": "Подключиться", + "folderSelection.servers.dialog.connecting": "Подключение...", + "folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.", + "folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.", + "folderSelection.servers.certificateInstall.title": "Установить локальный сертификат", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.", + "folderSelection.servers.certificateInstall.confirmLabel": "Продолжить", + "folderSelection.servers.certificateInstall.cancelLabel": "Отмена", + "folderSelection.servers.certificateInstall.cancelled": "CodeNomad должен доверять локальному сертификату, прежде чем сможет открывать удаленные HTTPS-окна с самоподписанным сертификатом.", "folderSelection.sidecars.button": "Открыть SideCar", "projectRenameDialog.title": "Переименовать рабочее пространство", diff --git a/packages/ui/src/lib/i18n/messages/ru/index.ts b/packages/ui/src/lib/i18n/messages/ru/index.ts index 73c60bbc8..838748067 100644 --- a/packages/ui/src/lib/i18n/messages/ru/index.ts +++ b/packages/ui/src/lib/i18n/messages/ru/index.ts @@ -9,6 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -31,6 +32,7 @@ export const ruMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, + remoteAccessMessages, remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts new file mode 100644 index 000000000..a711e2f65 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts @@ -0,0 +1,53 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "Удаленная передача управления", + "remoteAccess.title": "Подключитесь к CodeNomad удаленно", + "remoteAccess.subtitle": "Используйте адреса ниже, чтобы открыть CodeNomad с другого устройства.", + "remoteAccess.close": "Закрыть удаленный доступ", + "remoteAccess.refresh": "Обновить", + + "remoteAccess.sections.listeningMode.label": "Режим прослушивания", + "remoteAccess.sections.listeningMode.help": "Разрешайте или ограничивайте удаленную передачу управления, привязываясь ко всем интерфейсам или только к localhost.", + "remoteAccess.toggle.on": "Вкл", + "remoteAccess.toggle.off": "Выкл", + "remoteAccess.toggle.title": "Разрешить подключения с других IP", + "remoteAccess.toggle.caption.all": "Привязка к 0.0.0.0", + "remoteAccess.toggle.caption.local": "Привязка к 127.0.0.1", + "remoteAccess.toggle.note": "Изменение требует перезапуска и временно остановит все активные экземпляры. Поделитесь адресами ниже после перезапуска сервера.", + "remoteAccess.listeningMode.restartConfirm.message": "Перезапустить, чтобы применить режим прослушивания? Это остановит все запущенные экземпляры.", + "remoteAccess.listeningMode.restartConfirm.title.all": "Открыть для других устройств", + "remoteAccess.listeningMode.restartConfirm.title.local": "Ограничить этим устройством", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Перезапустить сейчас", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Отмена", + "remoteAccess.restart.errorManual": "Не удалось перезапустить автоматически. Перезапустите приложение, чтобы применить изменение.", + + "remoteAccess.sections.serverPassword.label": "Пароль сервера", + "remoteAccess.sections.serverPassword.help": "Для удаленной передачи управления требуется пароль. Установите запоминающийся пароль, чтобы разрешить вход с других устройств.", + "remoteAccess.authStatus.unavailable": "Статус аутентификации недоступен.", + "remoteAccess.username": "Имя пользователя: {username}", + "remoteAccess.password.status.set": "Для удаленного доступа установлен пароль.", + "remoteAccess.password.status.unset": "Пока не установлен запоминающийся пароль. Установите его, чтобы разрешить вход при удаленной передаче управления.", + "remoteAccess.password.actions.cancel": "Отмена", + "remoteAccess.password.actions.change": "Изменить пароль", + "remoteAccess.password.actions.set": "Установить пароль", + "remoteAccess.password.form.newPassword": "Новый пароль", + "remoteAccess.password.form.confirmPassword": "Подтвердите пароль", + "remoteAccess.password.form.placeholder": "Не менее 8 символов", + "remoteAccess.password.error.tooShort": "Пароль должен быть не короче 8 символов.", + "remoteAccess.password.error.mismatch": "Пароли не совпадают.", + "remoteAccess.password.save.saving": "Сохранение…", + "remoteAccess.password.save.label": "Сохранить пароль", + + "remoteAccess.sections.addresses.label": "Доступные адреса", + "remoteAccess.sections.addresses.help": "Откройте или отсканируйте с другой машины, чтобы передать управление.", + "remoteAccess.addresses.loading": "Загрузка адресов…", + "remoteAccess.addresses.none": "Пока нет доступных адресов.", + "remoteAccess.addresses.actions.showOther": "Показать еще {count} адресов", + "remoteAccess.addresses.actions.hideOther": "Скрыть остальные адреса", + "remoteAccess.address.scope.network": "Сеть", + "remoteAccess.address.scope.loopback": "Локальный loopback", + "remoteAccess.address.scope.internal": "Внутренний", + "remoteAccess.address.open": "Открыть", + "remoteAccess.address.showQr": "Показать QR", + "remoteAccess.address.hideQr": "Скрыть QR", + "remoteAccess.address.qrAlt": "QR для {url}", +} as const diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 45028e2d9..4b9b1e292 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Удерживать длинные ответы ассистента", "settings.behavior.holdLongAssistantReplies.subtitle": "Прекращать автоматическое следование, когда потоковый ответ выходит за пределы окна.", "settings.nav.notifications": "Уведомления", - "settings.nav.remote": "Удалённое управление", + "settings.nav.remote": "Удалённый доступ", "settings.nav.speech": "Речь", "settings.nav.providers": "Провайдеры", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Уведомления включены", "settings.notifications.status.disabled": "Уведомления отключены", "settings.notifications.status.unsupported": "Уведомления не поддерживаются", - "settings.section.remote.title": "Удалённое управление", - "settings.section.remote.subtitle": "Безопасно продолжайте сеансы этого устройства с другого через исходящий ретранслятор.", + "settings.section.remote.title": "Удалённый доступ", + "settings.section.remote.subtitle": "Проверьте, как этот сервер доступен в сети, и защитите учётные данные доступа.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Выберите бинарник OpenCode и окружение для новых экземпляров.", "settings.opencode.runtime.title": "Среда выполнения", diff --git a/packages/ui/src/lib/i18n/messages/tr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/tr/folderSelection.ts index c8bc131da..89645e51a 100644 --- a/packages/ui/src/lib/i18n/messages/tr/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/tr/folderSelection.ts @@ -36,6 +36,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "Depo URL'si ve hedef klasör zorunludur.", "folderSelection.actions.title": "Klasör Aç veya Sunucuya Bağlan", "folderSelection.actions.subtitle": "Yerel klasör aç veya bir CodeNomad sunucusuna bağlan", + "folderSelection.actions.connectButton": "CodeNomad Sunucusuna Bağlan", "folderSelection.advancedSettings": "Gelişmiş Ayarlar", "folderSelection.opencode": "OpenCode", "folderSelection.hints.navigate": "Gezin", @@ -51,6 +52,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Workspace Seç", "folderSelection.dialog.description": "Kod yazmaya başlamak için workspace seçin.", "folderSelection.tabs.local": "Yerel Klasörler", + "folderSelection.tabs.servers": "Sunucular", + "folderSelection.servers.title": "Kayıtlı Sunucular", + "folderSelection.servers.subtitle": "Kayıtlı bir uzak CodeNomad sunucusunu yeni pencerede aç", + "folderSelection.servers.count": "{count} Sunucu", + "folderSelection.servers.empty.title": "Kayıtlı Sunucu Yok", + "folderSelection.servers.empty.description": "Bu cihazdan hızlıca yeniden bağlanmak için bir uzak sunucu ekleyin", + "folderSelection.servers.connectTitle": "Sunucuya Bağlan", + "folderSelection.servers.connectSubtitle": "Bir uzak CodeNomad sunucusunu kaydedin ve yeni pencerede açın", + "folderSelection.servers.connectButton": "Sunucuya Bağlan", + "folderSelection.servers.remove": "Kayıtlı sunucuyu kaldır", + "folderSelection.servers.skipTls": "Kendinden imzalı TLS", + "folderSelection.servers.errorTitle": "Uzak Bağlantı Başarısız", + "folderSelection.servers.dialog.title": "Sunucuya Bağlan", + "folderSelection.servers.dialog.description": "Bir uzak CodeNomad sunucusu ekleyin ve isterseniz hemen açın.", + "folderSelection.servers.dialog.name": "Sunucu adı", + "folderSelection.servers.dialog.namePlaceholder": "Production Sunucusu", + "folderSelection.servers.dialog.url": "Sunucu URL'si", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Kendinden imzalı sertifikalar için TLS doğrulamasını atla.", + "folderSelection.servers.dialog.cancel": "İptal", + "folderSelection.servers.dialog.save": "Kaydet", + "folderSelection.servers.dialog.connect": "Bağlan", + "folderSelection.servers.dialog.connecting": "Bağlanıyor...", + "folderSelection.servers.dialog.errorRequired": "Sunucu adı ve URL zorunludur.", + "folderSelection.servers.dialog.errorConnect": "Uzak sunucuya bağlanılamadı.", + "folderSelection.servers.certificateInstall.title": "Yerel Sertifikayı Kur", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad'ın kendinden imzalı HTTPS uzak pencerelerini açmak için yerel bir sertifika kurması gerekiyor. Bu sertifika yalnızca makinenizdeki yerel masaüstü proxy trafiği için kullanılır. İşletim sisteminiz bundan sonra ikinci bir sertifika istemi gösterebilir.", + "folderSelection.servers.certificateInstall.confirmLabel": "Devam et", + "folderSelection.servers.certificateInstall.cancelLabel": "İptal", + "folderSelection.servers.certificateInstall.cancelled": "CodeNomad'ın kendinden imzalı HTTPS uzak pencerelerini açabilmesi için yerel sertifikanın güvenilir olması gerekir.", "folderSelection.sidecars.button": "SideCar'ı Aç", "projectRenameDialog.title": "Workspace'i yeniden adlandır", "projectRenameDialog.description.withLabel": "\"{label}\" için workspace adını güncelleyin.", diff --git a/packages/ui/src/lib/i18n/messages/tr/index.ts b/packages/ui/src/lib/i18n/messages/tr/index.ts index 829dfb4e3..8bfb26c08 100644 --- a/packages/ui/src/lib/i18n/messages/tr/index.ts +++ b/packages/ui/src/lib/i18n/messages/tr/index.ts @@ -10,6 +10,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -28,6 +29,7 @@ export const trMessages = mergeMessageParts( logMessages, markdownMessages, messagingMessages, + remoteAccessMessages, remoteControlMessages, sessionMessages, settingsMessages, diff --git a/packages/ui/src/lib/i18n/messages/tr/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/tr/remoteAccess.ts new file mode 100644 index 000000000..09e4f9209 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/tr/remoteAccess.ts @@ -0,0 +1,50 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "Uzak devir", + "remoteAccess.title": "CodeNomad'a uzaktan bağlan", + "remoteAccess.subtitle": "CodeNomad'ı başka bir cihazdan açmak için aşağıdaki adresleri kullanın.", + "remoteAccess.close": "Uzak erişimi kapat", + "remoteAccess.refresh": "Yenile", + "remoteAccess.sections.listeningMode.label": "Dinleme modu", + "remoteAccess.sections.listeningMode.help": "Sunucu tüm arayüzlerde ya da yalnızca localhost'ta dinleyerek uzak devirlere izin verir veya bunları sınırlar.", + "remoteAccess.toggle.on": "Açık", + "remoteAccess.toggle.off": "Kapalı", + "remoteAccess.toggle.title": "Diğer IP'lerden bağlantılara izin ver", + "remoteAccess.toggle.caption.all": "0.0.0.0 üzerinde dinleniyor", + "remoteAccess.toggle.caption.local": "127.0.0.1 üzerinde dinleniyor", + "remoteAccess.toggle.note": "Bu ayarı değiştirmek yeniden başlatma gerektirir ve geçici olarak tüm etkin instance'ları durdurur. Sunucu yeniden başlatıldıktan sonra aşağıdaki adresleri paylaşın.", + "remoteAccess.listeningMode.restartConfirm.message": "Dinleme modunu uygulamak için yeniden başlatılsın mı? Bu, çalışan tüm instance'ları durdurur.", + "remoteAccess.listeningMode.restartConfirm.title.all": "Diğer cihazlara aç", + "remoteAccess.listeningMode.restartConfirm.title.local": "Bu cihazla sınırla", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "Şimdi yeniden başlat", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "İptal", + "remoteAccess.restart.errorManual": "Otomatik olarak yeniden başlatılamıyor. Değişikliği uygulamak için uygulamayı yeniden başlatın.", + "remoteAccess.sections.serverPassword.label": "Sunucu şifresi", + "remoteAccess.sections.serverPassword.help": "Uzak devirler için şifre gerekir. Diğer cihazlardan oturum açmayı etkinleştirmek için akılda kalıcı bir şifre belirleyin.", + "remoteAccess.authStatus.unavailable": "Kimlik doğrulama durumu kullanılamıyor.", + "remoteAccess.username": "Kullanıcı adı: {username}", + "remoteAccess.password.status.set": "Uzak erişim için bir şifre belirlendi.", + "remoteAccess.password.status.unset": "Henüz akılda kalıcı bir şifre belirlenmedi. Uzak devir oturumlarına izin vermek için bir şifre belirleyin.", + "remoteAccess.password.actions.cancel": "İptal", + "remoteAccess.password.actions.change": "Şifreyi değiştir", + "remoteAccess.password.actions.set": "Şifre belirle", + "remoteAccess.password.form.newPassword": "Yeni şifre", + "remoteAccess.password.form.confirmPassword": "Şifreyi onayla", + "remoteAccess.password.form.placeholder": "En az 8 karakter", + "remoteAccess.password.error.tooShort": "Şifre en az 8 karakter olmalıdır.", + "remoteAccess.password.error.mismatch": "Şifreler eşleşmiyor.", + "remoteAccess.password.save.saving": "Kaydediliyor…", + "remoteAccess.password.save.label": "Şifreyi kaydet", + "remoteAccess.sections.addresses.label": "Erişilebilir adresler", + "remoteAccess.sections.addresses.help": "Kontrolü devretmek için başka bir makineden başlatın veya tarayın.", + "remoteAccess.addresses.loading": "Adresler yükleniyor…", + "remoteAccess.addresses.none": "Henüz kullanılabilir adres yok.", + "remoteAccess.addresses.actions.showOther": "Diğer {count} adresi göster", + "remoteAccess.addresses.actions.hideOther": "Diğer adresleri gizle", + "remoteAccess.address.scope.network": "Ağ", + "remoteAccess.address.scope.loopback": "Loopback", + "remoteAccess.address.scope.internal": "Dahili", + "remoteAccess.address.open": "Aç", + "remoteAccess.address.showQr": "QR göster", + "remoteAccess.address.hideQr": "QR gizle", + "remoteAccess.address.qrAlt": "{url} için QR kodu", +} as const diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index cd3388015..a8128f8ec 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -115,7 +115,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "Uzun asistan yanıtlarında takibi durdur", "settings.behavior.holdLongAssistantReplies.subtitle": "Streaming bir yanıt görünüm alanını aşınca otomatik takibi durdurur.", "settings.nav.notifications": "Bildirimler", - "settings.nav.remote": "Uzaktan Kontrol", + "settings.nav.remote": "Uzak Erişim", "settings.nav.speech": "Konuşma", "settings.nav.providers": "Provider'lar", "settings.nav.opencode": "OpenCode", @@ -216,8 +216,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "Bildirimler etkin", "settings.notifications.status.disabled": "Bildirimler devre dışı", "settings.notifications.status.unsupported": "Bildirimler desteklenmiyor", - "settings.section.remote.title": "Uzaktan Kontrol", - "settings.section.remote.subtitle": "Bu cihazdaki oturumları güvenli giden aktarıcı üzerinden başka bir cihazda sürdürün.", + "settings.section.remote.title": "Uzak Erişim", + "settings.section.remote.subtitle": "Bu sunucunun ağınızda nasıl sunulduğunu inceleyin ve erişim kimlik bilgilerini güvence altına alın.", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "Yeni instance'lar için kullanılacak OpenCode binary'sini ve ortamını seçin.", "settings.opencode.runtime.title": "Runtime", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts index 861be836f..f168c2de8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts @@ -41,6 +41,7 @@ export const folderSelectionMessages = { "folderSelection.clone.dialog.errorRequired": "仓库 URL 和目标文件夹为必填项。", "folderSelection.actions.title": "打开文件夹或连接服务器", "folderSelection.actions.subtitle": "打开本地文件夹或连接到 CodeNomad 服务器", + "folderSelection.actions.connectButton": "连接 CodeNomad 服务器", "folderSelection.advancedSettings": "高级设置", "folderSelection.opencode": "OpenCode", @@ -62,6 +63,36 @@ export const folderSelectionMessages = { "folderSelection.dialog.description": "选择工作区以开始编码。", "folderSelection.tabs.local": "本地文件夹", + "folderSelection.tabs.servers": "服务器", + "folderSelection.servers.title": "已保存的服务器", + "folderSelection.servers.subtitle": "在新窗口中打开已保存的远程 CodeNomad 服务器", + "folderSelection.servers.count": "{count} 个服务器", + "folderSelection.servers.empty.title": "没有已保存的服务器", + "folderSelection.servers.empty.description": "添加远程服务器,以便在此设备上快速重新连接", + "folderSelection.servers.connectTitle": "连接到服务器", + "folderSelection.servers.connectSubtitle": "保存远程 CodeNomad 服务器并在新窗口中打开它", + "folderSelection.servers.connectButton": "连接到服务器", + "folderSelection.servers.remove": "删除已保存服务器", + "folderSelection.servers.skipTls": "自签名 TLS", + "folderSelection.servers.errorTitle": "远程连接失败", + "folderSelection.servers.dialog.title": "连接到服务器", + "folderSelection.servers.dialog.description": "添加远程 CodeNomad 服务器,并可选择立即打开。", + "folderSelection.servers.dialog.name": "服务器名称", + "folderSelection.servers.dialog.namePlaceholder": "生产服务器", + "folderSelection.servers.dialog.url": "服务器 URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "为自签名证书跳过 TLS 验证。", + "folderSelection.servers.dialog.cancel": "取消", + "folderSelection.servers.dialog.save": "保存", + "folderSelection.servers.dialog.connect": "连接", + "folderSelection.servers.dialog.connecting": "连接中...", + "folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。", + "folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。", + "folderSelection.servers.certificateInstall.title": "安装本地证书", + "folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。", + "folderSelection.servers.certificateInstall.confirmLabel": "继续", + "folderSelection.servers.certificateInstall.cancelLabel": "取消", + "folderSelection.servers.certificateInstall.cancelled": "CodeNomad 需要先信任本地证书,才能打开使用自签名 HTTPS 的远程窗口。", "folderSelection.sidecars.button": "打开 SideCar", "projectRenameDialog.title": "重命名工作区", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts index 416458335..63b269f7a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts @@ -9,6 +9,7 @@ import { loadingScreenMessages } from "./loadingScreen" import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" +import { remoteAccessMessages } from "./remoteAccess" import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" @@ -31,6 +32,7 @@ export const zhHansMessages = mergeMessageParts( toolCallMessages, markdownMessages, settingsMessages, + remoteAccessMessages, remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts new file mode 100644 index 000000000..16a8b2656 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts @@ -0,0 +1,53 @@ +export const remoteAccessMessages = { + "remoteAccess.eyebrow": "远程接管", + "remoteAccess.title": "远程连接到 CodeNomad", + "remoteAccess.subtitle": "使用下面的地址从其他设备打开 CodeNomad。", + "remoteAccess.close": "关闭远程访问", + "remoteAccess.refresh": "刷新", + + "remoteAccess.sections.listeningMode.label": "监听模式", + "remoteAccess.sections.listeningMode.help": "通过绑定到所有接口或仅 localhost 来允许或限制远程接管。", + "remoteAccess.toggle.on": "开", + "remoteAccess.toggle.off": "关", + "remoteAccess.toggle.title": "允许其他 IP 连接", + "remoteAccess.toggle.caption.all": "绑定到 0.0.0.0", + "remoteAccess.toggle.caption.local": "绑定到 127.0.0.1", + "remoteAccess.toggle.note": "更改此项需要重启,并会暂时停止所有活动实例。服务器重启后再分享下方地址。", + "remoteAccess.listeningMode.restartConfirm.message": "重启以应用监听模式?这将停止所有正在运行的实例。", + "remoteAccess.listeningMode.restartConfirm.title.all": "对其他设备开放", + "remoteAccess.listeningMode.restartConfirm.title.local": "仅限此设备", + "remoteAccess.listeningMode.restartConfirm.confirmLabel": "立即重启", + "remoteAccess.listeningMode.restartConfirm.cancelLabel": "取消", + "remoteAccess.restart.errorManual": "无法自动重启。请手动重启应用以应用更改。", + + "remoteAccess.sections.serverPassword.label": "服务器密码", + "remoteAccess.sections.serverPassword.help": "远程接管需要密码。设置一个易记的密码,以允许其他设备登录。", + "remoteAccess.authStatus.unavailable": "无法获取认证状态。", + "remoteAccess.username": "用户名:{username}", + "remoteAccess.password.status.set": "已为远程访问设置密码。", + "remoteAccess.password.status.unset": "尚未设置易记密码。设置后可允许远程接管登录。", + "remoteAccess.password.actions.cancel": "取消", + "remoteAccess.password.actions.change": "修改密码", + "remoteAccess.password.actions.set": "设置密码", + "remoteAccess.password.form.newPassword": "新密码", + "remoteAccess.password.form.confirmPassword": "确认密码", + "remoteAccess.password.form.placeholder": "至少 8 个字符", + "remoteAccess.password.error.tooShort": "密码至少需要 8 个字符。", + "remoteAccess.password.error.mismatch": "两次输入的密码不一致。", + "remoteAccess.password.save.saving": "正在保存…", + "remoteAccess.password.save.label": "保存密码", + + "remoteAccess.sections.addresses.label": "可访问地址", + "remoteAccess.sections.addresses.help": "从另一台设备打开或扫描,以接管控制权。", + "remoteAccess.addresses.loading": "正在加载地址…", + "remoteAccess.addresses.none": "暂时没有可用地址。", + "remoteAccess.addresses.actions.showOther": "显示另外 {count} 个地址", + "remoteAccess.addresses.actions.hideOther": "隐藏其他地址", + "remoteAccess.address.scope.network": "网络", + "remoteAccess.address.scope.loopback": "回环", + "remoteAccess.address.scope.internal": "内部", + "remoteAccess.address.open": "打开", + "remoteAccess.address.showQr": "显示二维码", + "remoteAccess.address.hideQr": "隐藏二维码", + "remoteAccess.address.qrAlt": "{url} 的二维码", +} as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 920306fc3..8bb6d1f61 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -125,7 +125,7 @@ export const settingsMessages = { "settings.behavior.holdLongAssistantReplies.title": "暂停跟随较长的助手回复", "settings.behavior.holdLongAssistantReplies.subtitle": "当流式回复超出视口时停止自动跟随。", "settings.nav.notifications": "通知", - "settings.nav.remote": "远程控制", + "settings.nav.remote": "远程访问", "settings.nav.speech": "语音", "settings.nav.providers": "提供商", "settings.nav.opencode": "OpenCode", @@ -226,8 +226,8 @@ export const settingsMessages = { "settings.notifications.status.enabled": "通知已启用", "settings.notifications.status.disabled": "通知已禁用", "settings.notifications.status.unsupported": "不支持通知", - "settings.section.remote.title": "远程控制", - "settings.section.remote.subtitle": "通过安全的出站中继,从其他设备继续此设备上的会话。", + "settings.section.remote.title": "远程访问", + "settings.section.remote.subtitle": "查看此服务器如何暴露到网络,以及安全访问凭据。", "settings.section.opencode.title": "OpenCode", "settings.section.opencode.subtitle": "选择新实例使用的 OpenCode 可执行文件和环境。", "settings.opencode.runtime.title": "运行时", diff --git a/packages/ui/src/lib/native/remote-window.ts b/packages/ui/src/lib/native/remote-window.ts new file mode 100644 index 000000000..5237d537e --- /dev/null +++ b/packages/ui/src/lib/native/remote-window.ts @@ -0,0 +1,70 @@ +import { invoke } from "@tauri-apps/api/core" +import type { RemoteServerProfile } from "../../../../server/src/api-types" +import { showConfirmDialog } from "../../stores/alerts" +import { tGlobal } from "../i18n" +import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env" + +export interface RemoteWindowOpenPayload { + id: string + name: string + baseUrl: string + entryUrl?: string + proxySessionId?: string + skipTlsVerify: boolean +} + +export async function openRemoteServerWindow( + profile: Pick, + entryUrl?: string, + proxySessionId?: string, +): Promise { + if (!canOpenRemoteWindows()) { + throw new Error("Remote server windows can only be opened from a local desktop window") + } + + const payload: RemoteWindowOpenPayload = { + id: profile.id, + name: profile.name, + baseUrl: profile.baseUrl, + entryUrl, + proxySessionId, + skipTlsVerify: profile.skipTlsVerify, + } + + if (isElectronHost()) { + const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI + if (typeof api?.openRemoteWindow === "function") { + await api.openRemoteWindow(payload) + return + } + } + + if (isTauriHost()) { + const requiresLocalCertificate = + proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://") + + if (requiresLocalCertificate) { + const needsInstall = await invoke("needs_local_certificate_install") + if (needsInstall) { + const accepted = await showConfirmDialog( + tGlobal("folderSelection.servers.certificateInstall.confirmMessage"), + { + title: tGlobal("folderSelection.servers.certificateInstall.title"), + variant: "warning", + confirmLabel: tGlobal("folderSelection.servers.certificateInstall.confirmLabel"), + cancelLabel: tGlobal("folderSelection.servers.certificateInstall.cancelLabel"), + }, + ) + + if (!accepted) { + throw new Error(tGlobal("folderSelection.servers.certificateInstall.cancelled")) + } + } + } + + await invoke("open_remote_window", { payload }) + return + } + + window.open(profile.baseUrl, "_blank", "noopener,noreferrer") +} diff --git a/packages/ui/src/lib/remote-access-addresses.test.ts b/packages/ui/src/lib/remote-access-addresses.test.ts new file mode 100644 index 000000000..7161d035c --- /dev/null +++ b/packages/ui/src/lib/remote-access-addresses.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { splitRemoteAddresses } from "./remote-access-addresses" + +describe("splitRemoteAddresses", () => { + it("keeps the first remote address visible and collapses the rest", () => { + const result = splitRemoteAddresses([ + { ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" }, + { ip: "192.168.1.128", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.128:9898" }, + { ip: "172.24.96.1", family: "ipv4", scope: "external", remoteUrl: "https://172.24.96.1:9898" }, + ]) + + assert.equal(result.recommended?.ip, "192.168.1.128") + assert.deepEqual(result.hidden.map((address) => address.ip), ["172.24.96.1"]) + }) +}) diff --git a/packages/ui/src/lib/remote-access-addresses.ts b/packages/ui/src/lib/remote-access-addresses.ts new file mode 100644 index 000000000..e5aa8eb88 --- /dev/null +++ b/packages/ui/src/lib/remote-access-addresses.ts @@ -0,0 +1,14 @@ +import type { NetworkAddress } from "../../../server/src/api-types" + +export interface RemoteAddressGroups { + recommended: NetworkAddress | null + hidden: NetworkAddress[] +} + +export function splitRemoteAddresses(addresses: NetworkAddress[]): RemoteAddressGroups { + const remoteAddresses = addresses.filter((address) => address.scope !== "loopback") + return { + recommended: remoteAddresses[0] ?? null, + hidden: remoteAddresses.slice(1), + } +} diff --git a/packages/ui/src/lib/remote-control/bounded-body.test.ts b/packages/ui/src/lib/remote-control/bounded-body.test.ts new file mode 100644 index 000000000..47a7ede38 --- /dev/null +++ b/packages/ui/src/lib/remote-control/bounded-body.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { readBoundedBody } from "./bounded-body" + +test("reads request bodies only up to the transport limit", async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(1, 2)) + controller.enqueue(Uint8Array.of(3)) + controller.close() + }, + }) + assert.deepEqual(await readBoundedBody(source, 3), Uint8Array.of(1, 2, 3)) +}) + +test("cancels request body reads before retaining oversized input", async () => { + let cancelled = false + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4)) + controller.enqueue(new Uint8Array(4)) + }, + cancel() { + cancelled = true + }, + }) + assert.equal(await readBoundedBody(source, 4), null) + assert.equal(cancelled, true) +}) diff --git a/packages/ui/src/lib/remote-control/bounded-body.ts b/packages/ui/src/lib/remote-control/bounded-body.ts new file mode 100644 index 000000000..3ae2d072d --- /dev/null +++ b/packages/ui/src/lib/remote-control/bounded-body.ts @@ -0,0 +1,23 @@ +export async function readBoundedBody(stream: ReadableStream | null, maxBytes: number): Promise { + if (!stream) return new Uint8Array() + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > maxBytes) { + await reader.cancel() + return null + } + chunks.push(value) + } + const result = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.byteLength + } + return result +} diff --git a/packages/ui/src/lib/remote-control/event-source.test.ts b/packages/ui/src/lib/remote-control/event-source.test.ts index 5c100293a..aee5cc923 100644 --- a/packages/ui/src/lib/remote-control/event-source.test.ts +++ b/packages/ui/src/lib/remote-control/event-source.test.ts @@ -65,3 +65,38 @@ test("tunneled EventSource reconnects with the last event identifier", async () else delete (globalThis as { window?: unknown }).window } }) + +test("tunneled EventSource rejects an unbounded event line", async () => { + const originalFetch = globalThis.fetch + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + let sent = false + let cancelled = false + globalThis.fetch = async () => new Response(new ReadableStream({ + pull(controller) { + if (sent) return + sent = true + controller.enqueue(new TextEncoder().encode(`data: ${"x".repeat(1024 * 1024)}`)) + }, + cancel() { + cancelled = true + }, + })) + + try { + const source = new TunnelEventSource("/api/events") + let messages = 0 + source.onmessage = () => { messages += 1 } + await new Promise((resolve) => { source.onerror = () => resolve() }) + source.close() + assert.equal(messages, 0) + assert.equal(cancelled, true) + } finally { + globalThis.fetch = originalFetch + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + } +}) diff --git a/packages/ui/src/lib/remote-control/event-source.ts b/packages/ui/src/lib/remote-control/event-source.ts index 328c97276..38e2a7e22 100644 --- a/packages/ui/src/lib/remote-control/event-source.ts +++ b/packages/ui/src/lib/remote-control/event-source.ts @@ -1,5 +1,12 @@ type EventHandler = ((event: Event) => unknown) | null +const MAX_SSE_LINE_CHARS = 1024 * 1024 +const MAX_SSE_EVENT_CHARS = 4 * 1024 * 1024 +const MAX_SSE_EVENT_LINES = 4096 +const MAX_SSE_ID_CHARS = 8 * 1024 +const MIN_RECONNECT_DELAY_MS = 100 +const MAX_RECONNECT_DELAY_MS = 60_000 + export class TunnelEventSource extends EventTarget { static readonly CONNECTING = 0 static readonly OPEN = 1 @@ -74,6 +81,8 @@ export class TunnelEventSource extends EventTarget { let buffer = "" let eventName = "message" let eventData: string[] = [] + let eventCharacters = 0 + let eventLines = 0 let eventId = this.lastEventId const dispatch = () => { @@ -87,33 +96,56 @@ export class TunnelEventSource extends EventTarget { if (eventName === "message") this.onmessage?.(event) eventName = "message" eventData = [] + eventCharacters = 0 + eventLines = 0 } - while (!this.controller?.signal.aborted) { - const { done, value } = await reader.read() - buffer += decoder.decode(value, { stream: !done }) - let newline = buffer.indexOf("\n") - while (newline >= 0) { - let line = buffer.slice(0, newline) - buffer = buffer.slice(newline + 1) - if (line.endsWith("\r")) line = line.slice(0, -1) - if (!line) dispatch() - else if (!line.startsWith(":")) { - const separator = line.indexOf(":") - const field = separator < 0 ? line : line.slice(0, separator) - let data = separator < 0 ? "" : line.slice(separator + 1) - if (data.startsWith(" ")) data = data.slice(1) - if (field === "event") eventName = data || "message" - else if (field === "data") eventData.push(data) - else if (field === "id" && !data.includes("\0")) eventId = data - else if (field === "retry" && /^\d+$/.test(data)) this.reconnectDelay = Number(data) + let complete = false + try { + while (!this.controller?.signal.aborted) { + const { done, value } = await reader.read() + buffer += decoder.decode(value, { stream: !done }) + let newline = buffer.indexOf("\n") + while (newline >= 0) { + if (newline > MAX_SSE_LINE_CHARS) throw new Error("Remote event stream line exceeded its safety limit") + let line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + if (line.endsWith("\r")) line = line.slice(0, -1) + if (!line) dispatch() + else if (!line.startsWith(":")) { + const separator = line.indexOf(":") + const field = separator < 0 ? line : line.slice(0, separator) + let data = separator < 0 ? "" : line.slice(separator + 1) + if (data.startsWith(" ")) data = data.slice(1) + if (field === "event") eventName = data || "message" + else if (field === "data") { + eventCharacters += data.length + 1 + eventLines += 1 + if (eventCharacters > MAX_SSE_EVENT_CHARS || eventLines > MAX_SSE_EVENT_LINES) { + throw new Error("Remote event exceeded its safety limit") + } + eventData.push(data) + } + else if (field === "id" && !data.includes("\0")) { + if (data.length > MAX_SSE_ID_CHARS) throw new Error("Remote event identifier exceeded its safety limit") + eventId = data + } + else if (field === "retry" && /^\d+$/.test(data)) { + this.reconnectDelay = Math.max(MIN_RECONNECT_DELAY_MS, Math.min(MAX_RECONNECT_DELAY_MS, Number(data))) + } + } + newline = buffer.indexOf("\n") + } + if (buffer.length > MAX_SSE_LINE_CHARS) throw new Error("Remote event stream line exceeded its safety limit") + if (done) { + dispatch() + complete = true + break } - newline = buffer.indexOf("\n") - } - if (done) { - dispatch() - break } + } finally { + if (!complete) await reader.cancel().catch(() => undefined) + reader.releaseLock() } } diff --git a/packages/ui/src/lib/remote-control/tunnel.ts b/packages/ui/src/lib/remote-control/tunnel.ts index be8eb8833..00fb2c267 100644 --- a/packages/ui/src/lib/remote-control/tunnel.ts +++ b/packages/ui/src/lib/remote-control/tunnel.ts @@ -2,12 +2,15 @@ import { createClientHandshake, decodeBase64, encodeBase64, + FrameBudget, REMOTE_CONTROL_MAX_HTTP_BODY_BYTES, + REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES, type ClientToHostMessage, type EncryptedChannel, type HeaderEntries, type HostToClientMessage, } from "@codenomad/remote-control-protocol" +import { readBoundedBody } from "./bounded-body" import { TunnelEventSource } from "./event-source" import { TunnelWebSocket, @@ -19,6 +22,13 @@ import { const HOST_KEY_STORAGE = "codenomad.remote-control.host-public-key" const HTTP_IDLE_TIMEOUT_MS = 30_000 const FETCH_PATH_PREFIXES = ["/api/", "/workspaces/"] +const MAX_ACTIVE_HTTP_REQUESTS = 32 +const MAX_PENDING_SOCKETS = 16 +const MAX_PENDING_FRAMES = 128 +const MAX_PENDING_FRAME_BYTES = 24 * 1024 * 1024 +const MAX_BUFFERED_HTTP_CHUNKS = 512 +const MAX_BUFFERED_HTTP_BYTES = 24 * 1024 * 1024 +const SOCKET_CLOSE_TIMEOUT_MS = 10_000 interface RemoteControlBootstrap { tunnelPath: string @@ -28,16 +38,20 @@ interface PendingHttp { resolve: (response: Response) => void reject: (error: Error) => void controller?: ReadableStreamDefaultController - queued: Uint8Array[] + queued: Array<{ bytes: Uint8Array; release: () => void }> + inFlightRelease?: () => void ended: boolean timeout: ReturnType cleanup: () => void + releaseAdmission: () => void + method: string } interface PendingSocket { socket: TunnelWebSocket opening: Promise transmission: Promise + closeTimer?: ReturnType } export async function installRemoteControlTransport(): Promise { @@ -78,8 +92,13 @@ class RemoteControlTunnel implements RemoteSocketBridge { private connection: Promise | null = null private sendQueue = Promise.resolve() private receiveQueue = Promise.resolve() + private readonly sendBudget = new FrameBudget(MAX_PENDING_FRAMES, MAX_PENDING_FRAME_BYTES) + private readonly receiveBudget = new FrameBudget(MAX_PENDING_FRAMES, MAX_PENDING_FRAME_BYTES) + private readonly socketBudget = new FrameBudget(MAX_PENDING_FRAMES, MAX_PENDING_FRAME_BYTES) + private readonly httpBufferBudget = new FrameBudget(MAX_BUFFERED_HTTP_CHUNKS, MAX_BUFFERED_HTTP_BYTES) private readonly pendingHttp = new Map() private readonly pendingSockets = new Map() + private activeHttpRequests = 0 constructor( private readonly bootstrap: RemoteControlBootstrap, @@ -92,19 +111,38 @@ class RemoteControlTunnel implements RemoteSocketBridge { const request = input instanceof Request && init ? new Request(input, init) : source const id = crypto.randomUUID() if (request.signal.aborted) throw new DOMException("The operation was aborted", "AbortError") - const bodyBytes = request.method === "GET" || request.method === "HEAD" - ? new Uint8Array() - : new Uint8Array(await request.arrayBuffer()) - if (bodyBytes.byteLength > REMOTE_CONTROL_MAX_HTTP_BODY_BYTES) return Response.json({ error: "Remote request body is too large" }, { status: 413 }) - - await this.ensureConnected() + if (this.activeHttpRequests >= MAX_ACTIVE_HTTP_REQUESTS) { + return Response.json({ error: "Too many active remote requests" }, { status: 429 }) + } + this.activeHttpRequests += 1 + let admissionActive = true + const releaseAdmission = () => { + if (!admissionActive) return + admissionActive = false + this.activeHttpRequests -= 1 + } + let bodyBytes: Uint8Array | null + try { + bodyBytes = request.method === "GET" || request.method === "HEAD" + ? new Uint8Array() + : await readBoundedBody(request.body, REMOTE_CONTROL_MAX_HTTP_BODY_BYTES) + if (request.signal.aborted) throw new DOMException("The operation was aborted", "AbortError") + if (!bodyBytes) { + releaseAdmission() + return Response.json({ error: "Remote request body is too large" }, { status: 413 }) + } + await this.ensureConnected() + } catch (error) { + releaseAdmission() + throw error + } const abort = () => { - void this.send({ type: "http.cancel", id }) + void this.send({ type: "http.cancel", id }).catch(() => undefined) this.failHttp(id, new DOMException("The operation was aborted", "AbortError")) } const response = new Promise((resolve, reject) => { const timeout = setTimeout(() => { - void this.send({ type: "http.cancel", id }) + void this.send({ type: "http.cancel", id }).catch(() => undefined) this.failHttp(id, new Error("Remote request timed out")) }, HTTP_IDLE_TIMEOUT_MS) this.pendingHttp.set(id, { @@ -114,6 +152,8 @@ class RemoteControlTunnel implements RemoteSocketBridge { ended: false, timeout, cleanup: () => request.signal.removeEventListener("abort", abort), + releaseAdmission, + method: request.method, }) }) request.signal.addEventListener("abort", abort, { once: true }) @@ -131,6 +171,13 @@ class RemoteControlTunnel implements RemoteSocketBridge { } connectSocket(socket: TunnelWebSocket, url: URL, protocols: string[]): void { + if (this.pendingSockets.size >= MAX_PENDING_SOCKETS) { + queueMicrotask(() => { + socket.fail() + socket.finish(1008, "Too many active remote WebSockets", false) + }) + return + } const opening = this.ensureConnected() .then(() => this.send({ type: "socket.open", @@ -146,9 +193,23 @@ class RemoteControlTunnel implements RemoteSocketBridge { transmitSocket(socket: TunnelWebSocket, data: RemoteSocketData): void { const pending = this.pendingSockets.get(socket.id) if (!pending || pending.socket !== socket) return + const byteLength = socketPayloadByteLength(data) + if (byteLength > REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES) { + this.failSocket(socket.id) + return + } + const release = this.socketBudget.reserve(byteLength) + if (!release) { + this.failSocket(socket.id) + return + } + socket.buffer(byteLength) const transmission = pending.transmission.then(async () => { const payload = await socketPayload(data) await this.send({ type: "socket.message", id: socket.id, data: encodeBase64(payload.bytes), binary: payload.binary }) + }).finally(() => { + release() + socket.flush(byteLength) }) pending.transmission = transmission.catch(() => undefined) void transmission.catch(() => this.failSocket(socket.id)) @@ -162,7 +223,17 @@ class RemoteControlTunnel implements RemoteSocketBridge { } void pending.transmission .then(() => this.send({ type: "socket.close", id: socket.id, code, reason })) - .catch(() => undefined) + .then(() => { + if (this.pendingSockets.get(socket.id) !== pending) return + pending.closeTimer = setTimeout(() => { + pending.socket.fail() + this.finishSocket(socket.id, 1006, "Remote WebSocket close timed out", false) + }, SOCKET_CLOSE_TIMEOUT_MS) + }) + .catch(() => { + pending.socket.fail() + this.finishSocket(socket.id, 1006, "Remote WebSocket close failed", false) + }) } private ensureConnected(): Promise { @@ -219,10 +290,16 @@ class RemoteControlTunnel implements RemoteSocketBridge { this.close(1002, "Plaintext received after encryption handshake") return } + const frame = new Uint8Array(event.data) + const release = this.receiveBudget.reserve(frame.byteLength) + if (!release) { + this.close(1009, "Remote Control receive queue exceeded its safety limit") + return + } const receive = this.receiveQueue.then(async () => { if (this.socket !== socket || this.channel !== channel) return - await this.receive(channel, new Uint8Array(event.data)) - }) + await this.receive(channel, frame) + }).finally(release) this.receiveQueue = receive.catch(() => { if (this.socket === socket) this.close(1008, "Encrypted Remote Control frame failed") }) @@ -233,6 +310,11 @@ class RemoteControlTunnel implements RemoteSocketBridge { private send(message: ClientToHostMessage): Promise { const plaintext = new TextEncoder().encode(JSON.stringify(message)) + const release = this.sendBudget.reserve(plaintext.byteLength) + if (!release) { + this.close(1009, "Remote Control send queue exceeded its safety limit") + return Promise.reject(new Error("Remote Control send queue exceeded its safety limit")) + } const socket = this.socket const channel = this.channel const send = this.sendQueue.then(async () => { @@ -244,7 +326,7 @@ class RemoteControlTunnel implements RemoteSocketBridge { throw new Error("Remote Control tunnel is disconnected") } socket.send(frame) - }) + }).finally(release) this.sendQueue = send.catch(() => undefined) return send } @@ -257,7 +339,11 @@ class RemoteControlTunnel implements RemoteSocketBridge { else if (message.type === "http.end") this.endHttp(message.id) else if (message.type === "http.error") this.failHttp(message.id, new Error(message.message)) else if (message.type === "socket.ready") this.pendingSockets.get(message.id)?.socket.accept(message.protocol) - else if (message.type === "socket.message") this.pendingSockets.get(message.id)?.socket.receive(decodeBase64(message.data), message.binary) + else if (message.type === "socket.message") { + if (base64ByteLength(message.data) > REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES) { + this.failSocket(message.id) + } else this.pendingSockets.get(message.id)?.socket.receive(decodeBase64(message.data), message.binary) + } else if (message.type === "socket.error") this.failSocket(message.id) else if (message.type === "socket.close") this.finishSocket(message.id, message.code, message.reason) } @@ -266,21 +352,24 @@ class RemoteControlTunnel implements RemoteSocketBridge { const pending = this.pendingHttp.get(message.id) if (!pending) return this.refreshTimeout(message.id) - const stream = new ReadableStream({ + const bodyAllowed = pending.method !== "HEAD" && !responseMustNotHaveBody(message.status) + const stream = bodyAllowed ? new ReadableStream({ start: (controller) => { pending.controller = controller - for (const chunk of pending.queued) controller.enqueue(chunk) - pending.queued.length = 0 - if (pending.ended) controller.close() + this.pumpHttp(message.id, pending) + }, + pull: () => { + pending.inFlightRelease?.() + pending.inFlightRelease = undefined + this.refreshTimeout(message.id) + this.pumpHttp(message.id, pending) }, cancel: () => { - void this.send({ type: "http.cancel", id: message.id }) - clearTimeout(pending.timeout) - pending.cleanup() - this.pendingHttp.delete(message.id) + void this.send({ type: "http.cancel", id: message.id }).catch(() => undefined) + this.releaseHttp(message.id, pending) }, - }) - pending.resolve(new Response(responseMustNotHaveBody(message.status) ? null : stream, { + }, { highWaterMark: 1 }) : null + pending.resolve(new Response(stream, { status: message.status, headers: message.headers, })) @@ -290,28 +379,31 @@ class RemoteControlTunnel implements RemoteSocketBridge { const pending = this.pendingHttp.get(id) if (!pending || pending.ended) return this.refreshTimeout(id) - if (pending.controller) pending.controller.enqueue(chunk) - else pending.queued.push(chunk) + const release = this.httpBufferBudget.reserve(chunk.byteLength) + if (!release) { + void this.send({ type: "http.cancel", id }).catch(() => undefined) + this.failHttp(id, new Error("Remote response buffer exceeded its safety limit")) + return + } + pending.queued.push({ bytes: chunk, release }) + this.pumpHttp(id, pending) } private endHttp(id: string): void { const pending = this.pendingHttp.get(id) if (!pending) return - clearTimeout(pending.timeout) - pending.cleanup() pending.ended = true - pending.controller?.close() - this.pendingHttp.delete(id) + pending.releaseAdmission() + this.refreshTimeout(id) + this.pumpHttp(id, pending) } private failHttp(id: string, error: Error): void { const pending = this.pendingHttp.get(id) if (!pending) return - clearTimeout(pending.timeout) - pending.cleanup() pending.reject(error) pending.controller?.error(error) - this.pendingHttp.delete(id) + this.releaseHttp(id, pending) } private refreshTimeout(id: string): void { @@ -319,11 +411,39 @@ class RemoteControlTunnel implements RemoteSocketBridge { if (!pending) return clearTimeout(pending.timeout) pending.timeout = setTimeout(() => { - void this.send({ type: "http.cancel", id }) + void this.send({ type: "http.cancel", id }).catch(() => undefined) this.failHttp(id, new Error("Remote response timed out")) }, HTTP_IDLE_TIMEOUT_MS) } + private pumpHttp(id: string, pending: PendingHttp): void { + if (this.pendingHttp.get(id) !== pending || pending.inFlightRelease) return + if (!pending.controller) { + if (pending.ended) this.releaseHttp(id, pending) + return + } + const next = pending.queued.shift() + if (next) { + pending.inFlightRelease = next.release + pending.controller.enqueue(next.bytes) + return + } + if (!pending.ended) return + pending.controller.close() + this.releaseHttp(id, pending) + } + + private releaseHttp(id: string, pending: PendingHttp): void { + if (this.pendingHttp.get(id) !== pending) return + clearTimeout(pending.timeout) + pending.cleanup() + pending.releaseAdmission() + pending.inFlightRelease?.() + for (const queued of pending.queued) queued.release() + pending.queued.length = 0 + this.pendingHttp.delete(id) + } + private close(code?: number, reason?: string): void { const socket = this.socket this.socket = null @@ -349,6 +469,7 @@ class RemoteControlTunnel implements RemoteSocketBridge { const pending = this.pendingSockets.get(id) if (!pending) return this.pendingSockets.delete(id) + if (pending.closeTimer) clearTimeout(pending.closeTimer) pending.socket.finish(code, reason, wasClean) } } @@ -385,6 +506,12 @@ async function socketPayload(data: RemoteSocketData): Promise<{ bytes: Uint8Arra return { bytes: new Uint8Array(data), binary: true } } +function socketPayloadByteLength(data: RemoteSocketData): number { + if (typeof data === "string") return new TextEncoder().encode(data).byteLength + if (data instanceof Blob) return data.size + return data.byteLength +} + function requestHeaders(headers: Headers): HeaderEntries { const result: HeaderEntries = [] headers.forEach((value, name) => result.push([name, value])) @@ -408,6 +535,12 @@ function parseHostMessage(value: string): HostToClientMessage | null { } } +function base64ByteLength(value: string): number { + if (!value) return 0 + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0 + return Math.floor(value.length * 3 / 4) - padding +} + function responseMustNotHaveBody(status: number): boolean { return status === 204 || status === 205 || status === 304 } diff --git a/packages/ui/src/lib/remote-control/web-socket.test.ts b/packages/ui/src/lib/remote-control/web-socket.test.ts index 47b080855..8a8d18ee3 100644 --- a/packages/ui/src/lib/remote-control/web-socket.test.ts +++ b/packages/ui/src/lib/remote-control/web-socket.test.ts @@ -38,6 +38,10 @@ test("tunneled WebSocket preserves text, binary, protocol, and close events", () socket.onopen = () => events.push("open") socket.onmessage = (event) => events.push(`message:${event.data}`) socket.onclose = (event) => events.push(`close:${event.code}:${event.wasClean}`) + socket.buffer(12) + assert.equal(socket.bufferedAmount, 12) + socket.flush(5) + assert.equal(socket.bufferedAmount, 7) socket.accept("v2") socket.receive(new TextEncoder().encode("hello"), false) socket.send("request") @@ -56,3 +60,35 @@ test("tunneled WebSocket preserves text, binary, protocol, and close events", () else delete (globalThis as { CloseEvent?: unknown }).CloseEvent } }) + +test("tunneled WebSocket rejects transport metadata outside its bounded contract", () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + const bridge: RemoteSocketBridge = { + connectSocket: () => assert.fail("invalid socket must not connect"), + transmitSocket: () => {}, + disconnectSocket: () => {}, + } + try { + assert.throws( + () => new TunnelWebSocket("/api/socket", Array.from({ length: 17 }, () => crypto.randomUUID()), bridge), + /Invalid WebSocket protocol/, + ) + assert.throws( + () => new TunnelWebSocket("/api/socket", [1] as unknown as string[], bridge), + /Invalid WebSocket protocol/, + ) + const socket = new TunnelWebSocket("/api/socket", undefined, { + ...bridge, + connectSocket: () => {}, + }) + assert.throws(() => socket.close(Number.NaN), /Invalid WebSocket close code/) + assert.throws(() => socket.close(3000.5), /Invalid WebSocket close code/) + } finally { + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + } +}) diff --git a/packages/ui/src/lib/remote-control/web-socket.ts b/packages/ui/src/lib/remote-control/web-socket.ts index cacb397b0..9b6337644 100644 --- a/packages/ui/src/lib/remote-control/web-socket.ts +++ b/packages/ui/src/lib/remote-control/web-socket.ts @@ -10,6 +10,9 @@ type OpenHandler = ((event: Event) => unknown) | null type MessageHandler = ((event: MessageEvent) => unknown) | null type CloseHandler = ((event: CloseEvent) => unknown) | null +const MAX_PROTOCOLS = 16 +const MAX_PROTOCOL_CHARS = 128 + export class TunnelWebSocket extends EventTarget { static readonly CONNECTING = 0 static readonly OPEN = 1 @@ -24,7 +27,7 @@ export class TunnelWebSocket extends EventTarget { readonly url: string readonly extensions = "" binaryType: BinaryType = "blob" - bufferedAmount = 0 + private bufferedBytes = 0 protocol = "" readyState = TunnelWebSocket.CONNECTING onopen: OpenHandler = null @@ -32,6 +35,10 @@ export class TunnelWebSocket extends EventTarget { onerror: OpenHandler = null onclose: CloseHandler = null + get bufferedAmount(): number { + return this.bufferedBytes + } + constructor(url: string | URL, protocols: string | string[] | undefined, private readonly bridge: RemoteSocketBridge) { super() const target = new URL(url, window.location.href) @@ -65,6 +72,14 @@ export class TunnelWebSocket extends EventTarget { this.onopen?.(event) } + buffer(byteLength: number): void { + this.bufferedBytes += byteLength + } + + flush(byteLength: number): void { + this.bufferedBytes = Math.max(0, this.bufferedBytes - byteLength) + } + receive(data: Uint8Array, binary: boolean): void { if (this.readyState !== TunnelWebSocket.OPEN) return const payload = binary @@ -87,6 +102,7 @@ export class TunnelWebSocket extends EventTarget { finish(code = 1005, reason = "", wasClean = true): void { if (this.readyState === TunnelWebSocket.CLOSED) return this.readyState = TunnelWebSocket.CLOSED + this.bufferedBytes = 0 const event = new CloseEvent("close", { code, reason, wasClean }) this.dispatchEvent(event) this.onclose?.(event) @@ -116,15 +132,16 @@ export function tunnelAwareWebSocket( function normalizeProtocols(value: string | string[] | undefined): string[] { const protocols = value === undefined ? [] : typeof value === "string" ? [value] : [...value] - if (new Set(protocols).size !== protocols.length - || protocols.some((protocol) => !/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(protocol))) { + if (protocols.length > MAX_PROTOCOLS || new Set(protocols).size !== protocols.length + || protocols.some((protocol) => typeof protocol !== "string" || protocol.length > MAX_PROTOCOL_CHARS + || !/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(protocol))) { throw new DOMException("Invalid WebSocket protocol", "SyntaxError") } return protocols } function validateClose(code: number | undefined, reason: string): void { - if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) { + if (code !== undefined && (!Number.isInteger(code) || (code !== 1000 && (code < 3000 || code > 4999)))) { throw new DOMException("Invalid WebSocket close code", "InvalidAccessError") } if (new TextEncoder().encode(reason).byteLength > 123) { diff --git a/packages/ui/src/lib/runtime-env.test.ts b/packages/ui/src/lib/runtime-env.test.ts index 9012bb1be..e9484fc98 100644 --- a/packages/ui/src/lib/runtime-env.test.ts +++ b/packages/ui/src/lib/runtime-env.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { canRestartCli, canUseNativeDialogs, isLocalTauriHost, usesClientState, type RuntimeEnvironment } from "./runtime-env.ts" +import { canOpenRemoteWindows, canRestartCli, canUseNativeDialogs, isLocalTauriHost, usesClientState, type RuntimeEnvironment } from "./runtime-env.ts" const environment = (host: RuntimeEnvironment["host"], windowContext: RuntimeEnvironment["windowContext"]) => ({ host, @@ -12,7 +12,7 @@ describe("isLocalTauriHost", () => { assert.equal(isLocalTauriHost(environment("tauri", "local")), true) }) - it("keeps native-only features disabled in non-local Tauri contexts", () => { + it("keeps native-only features disabled in remote Tauri windows", () => { assert.equal(isLocalTauriHost(environment("tauri", "remote")), false) }) @@ -43,6 +43,7 @@ describe("Preferences native capabilities", () => { }) try { assert.equal(canUseNativeDialogs(), true) + assert.equal(canOpenRemoteWindows(), true) assert.equal(canRestartCli(), true) } finally { Object.assign(globalThis, { window: previousWindow }) diff --git a/packages/ui/src/lib/runtime-env.ts b/packages/ui/src/lib/runtime-env.ts index 5e773251c..45935338b 100644 --- a/packages/ui/src/lib/runtime-env.ts +++ b/packages/ui/src/lib/runtime-env.ts @@ -127,11 +127,13 @@ export const isLocalTauriHost = ( export const isDesktopHost = () => isElectronHost() || isTauriHost() export const isMobilePlatform = () => detectPlatform() === "mobile" export const isLocalWindow = () => detectWindowContext() === "local" +export const isRemoteWindow = () => detectWindowContext() === "remote" export const isPreferencesWindow = () => detectWindowContext() === "preferences" export const usesClientState = ( environment: Pick = detectRuntimeEnvironment(), ) => environment.windowContext !== "preferences" export const isNativeApplicationWindow = () => isLocalWindow() || isPreferencesWindow() export const canUseNativeDialogs = () => isDesktopHost() && isNativeApplicationWindow() +export const canOpenRemoteWindows = () => isDesktopHost() && isNativeApplicationWindow() export const canRestartCli = () => isDesktopHost() && isNativeApplicationWindow() export const canUseDesktopFolderDrop = () => isDesktopHost() && isLocalWindow() diff --git a/packages/ui/src/stores/opencode-data.test.ts b/packages/ui/src/stores/opencode-data.test.ts index 9a94215d3..dd1628c8b 100644 --- a/packages/ui/src/stores/opencode-data.test.ts +++ b/packages/ui/src/stores/opencode-data.test.ts @@ -8,7 +8,6 @@ import { applyOpenCodeDataEvent, destroyOpenCodeData, getOpenCodeMessageRevision import { emptyLatestWindow } from "./message-v2/message-window.ts" import { getRootClient } from "./opencode-client.ts" import { sdkManager } from "../lib/sdk-manager.ts" -import { sseManager } from "../lib/sse-manager.ts" function deferred() { let resolve!: (value: T) => void @@ -926,7 +925,6 @@ describe("OpenCode data projection", () => { it("processes a rotation-boundary side-effect event exactly once", async () => { const instanceId = "opencode-data-single-side-effect" const sessionId = "session" - sseManager.seedStatus(instanceId, "connected") const client = getRootClient(instanceId) let reads = 0 ;(client.session as any).message = async ({ messageID }: { messageID: string }) => { diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 5d88f4966..7aadfdce4 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,6 +1,7 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" import { storage, type OwnerBucket } from "../lib/storage" +import type { RemoteServerProfile } from "../../../server/src/api-types" import { ensureInstanceConfigLoaded, getInstanceConfig, @@ -50,6 +51,7 @@ export type VisibilityPreference = "hidden" | ExpansionPreference export type ToolCallExpansionPreset = "minimal" | "balanced" | "detailed" | "everything" export type ToolCallExpansionPresetSelection = ToolCallExpansionPreset | "custom" export type ToolInputsVisibilityPreference = VisibilityPreference +export type ListeningMode = "local" | "all" export type ServerLogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" export type SpeechProviderPreference = "openai-compatible" export type SpeechPlaybackMode = "streaming" | "buffered" @@ -154,6 +156,7 @@ interface UiConfigBucket { } interface ServerConfigBucket { + listeningMode?: ListeningMode logLevel?: ServerLogLevel environmentVariables?: Record secureEnvVars?: string[] @@ -169,6 +172,7 @@ interface UiStateBucket { activeColorSchemePresetId?: string recentFolders?: RecentFolder[] opencodeBinaries?: OpenCodeBinary[] + remoteServers?: RemoteServerProfile[] models?: { recents?: ModelPreference[] favorites?: ModelPreference[] @@ -179,6 +183,7 @@ interface UiStateBucket { interface NormalizedUiState { recentFolders: RecentFolder[] opencodeBinaries: OpenCodeBinary[] + remoteServers: RemoteServerProfile[] models: { recents: ModelPreference[] favorites: ModelPreference[] @@ -435,6 +440,29 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { const label = typeof (b as any).label === "string" ? (b as any).label : undefined return { path: p, version, label, lastUsed } }), + remoteServers: cloneArray(source.remoteServers, (server) => { + if (!server || typeof server !== "object") return null + const id = typeof (server as any).id === "string" ? (server as any).id.trim() : "" + const name = typeof (server as any).name === "string" ? (server as any).name.trim() : "" + const baseUrl = typeof (server as any).baseUrl === "string" ? (server as any).baseUrl.trim() : "" + if (!id || !name || !baseUrl) return null + const createdAt = typeof (server as any).createdAt === "string" ? (server as any).createdAt : new Date().toISOString() + const updatedAt = typeof (server as any).updatedAt === "string" ? (server as any).updatedAt : createdAt + const lastConnectedAt = typeof (server as any).lastConnectedAt === "string" ? (server as any).lastConnectedAt : undefined + return { + id, + name, + baseUrl, + skipTlsVerify: Boolean((server as any).skipTlsVerify), + createdAt, + updatedAt, + lastConnectedAt, + } + }).sort((a, b) => { + const left = a.lastConnectedAt ?? a.updatedAt + const right = b.lastConnectedAt ?? b.updatedAt + return right.localeCompare(left) + }), models: { recents: cloneArray((source.models as any)?.recents, (m) => { if (!m || typeof m !== "object") return null @@ -457,8 +485,9 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { export function normalizeServerConfig( input?: ServerConfigBucket | null, -): Required> & { speech: SpeechSettings } { +): Required> & { speech: SpeechSettings } { const source = input ?? {} + const listeningMode = source.listeningMode === "all" ? "all" : "local" const logLevel = source.logLevel === "INFO" || source.logLevel === "WARN" || source.logLevel === "ERROR" || source.logLevel === "DEBUG" ? source.logLevel @@ -469,7 +498,7 @@ export function normalizeServerConfig( const environmentVariables = normalizeRecord(source.environmentVariables) const secureEnvVars = normalizeSecureEnvVars(source.secureEnvVars) const speech = normalizeSpeechSettings(source.speech) - return { logLevel, opencodeBinary, environmentVariables, secureEnvVars, speech } + return { listeningMode, logLevel, opencodeBinary, environmentVariables, secureEnvVars, speech } } function normalizeSecureEnvVars(input?: unknown): string[] { @@ -510,6 +539,43 @@ export function buildBinaryList(binaryPath: string, version: string | undefined, return [nextEntry, ...source].slice(0, 10) } +interface RemoteServerProfileInput { + id?: string + name: string + baseUrl: string + skipTlsVerify: boolean +} + +function buildRemoteServerProfile(input: RemoteServerProfileInput, source: RemoteServerProfile[]): RemoteServerProfile { + const existing = input.id ? source.find((entry) => entry.id === input.id) : undefined + const now = new Date().toISOString() + return { + id: existing?.id ?? input.id ?? createRandomId(), + name: input.name.trim(), + baseUrl: input.baseUrl.trim(), + skipTlsVerify: Boolean(input.skipTlsVerify), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + lastConnectedAt: existing?.lastConnectedAt, + } +} + +function buildRemoteServerList(profile: RemoteServerProfile, source: RemoteServerProfile[]): RemoteServerProfile[] { + const remaining = source.filter((entry) => entry.id !== profile.id) + return [profile, ...remaining].sort((a, b) => { + const left = a.lastConnectedAt ?? a.updatedAt + const right = b.lastConnectedAt ?? b.updatedAt + return right.localeCompare(left) + }) +} + +function createRandomId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID() + } + return `remote-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + const [uiConfigBucket, setUiConfigBucket] = createSignal({}) const [serverConfigBucket, setServerConfigBucket] = createSignal({}) const [uiStateBucket, setUiStateBucket] = createSignal({}) @@ -540,6 +606,7 @@ const uiState = createMemo(() => normalizeUiState(uiStateBucket())) const preferences = uiSettings const recentFolders = createMemo(() => uiState().recentFolders) const opencodeBinaries = createMemo(() => uiState().opencodeBinaries) +const remoteServers = createMemo(() => uiState().remoteServers) let loadPromise: Promise | null = null @@ -720,6 +787,11 @@ function saveColorSchemePreset(name: string, appearance: "light" | "dark", color return write.then(() => id) } + async function setListeningMode(mode: ListeningMode): Promise { + if (serverSettings().listeningMode === mode) return + await patchConfigOwner("server", { listeningMode: mode }) + } + function updateEnvironmentVariables(envVars: Record): void { void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) => log.error("Failed to update environment variables", error), @@ -836,6 +908,29 @@ async function renameRecentFolderProject(folderPath: string, projectName: string } } +async function saveRemoteServerProfile(input: RemoteServerProfileInput): Promise { + const profile = buildRemoteServerProfile(input, remoteServers()) + await patchStateOwner("ui", { remoteServers: buildRemoteServerList(profile, remoteServers()) }) + return profile +} + +async function markRemoteServerConnected(id: string): Promise { + const current = remoteServers().find((entry) => entry.id === id) + if (!current) return + const now = new Date().toISOString() + const updated: RemoteServerProfile = { + ...current, + updatedAt: now, + lastConnectedAt: now, + } + await patchStateOwner("ui", { remoteServers: buildRemoteServerList(updated, remoteServers()) }) +} + +function removeRemoteServerProfile(id: string): void { + const next = remoteServers().filter((entry) => entry.id !== id) + void patchStateOwner("ui", { remoteServers: next }).catch((error) => log.error("Failed to remove remote server", error)) +} + function recordWorkspaceLaunch(folderPath: string, aliasPath?: string): void { const nextFolders = buildRecentFolderList(folderPath, recentFolders(), aliasPath) @@ -1016,6 +1111,7 @@ interface ConfigContextValue { // server-owned stable config serverSettings: typeof serverSettings + setListeningMode: typeof setListeningMode updateEnvironmentVariables: typeof updateEnvironmentVariables addEnvironmentVariable: typeof addEnvironmentVariable removeEnvironmentVariable: typeof removeEnvironmentVariable @@ -1028,12 +1124,16 @@ interface ConfigContextValue { // ui-owned state recentFolders: typeof recentFolders opencodeBinaries: typeof opencodeBinaries + remoteServers: typeof remoteServers uiState: typeof uiState addRecentFolder: typeof addRecentFolder removeRecentFolder: typeof removeRecentFolder renameRecentFolderProject: typeof renameRecentFolderProject addOpenCodeBinary: typeof addOpenCodeBinary removeOpenCodeBinary: typeof removeOpenCodeBinary + saveRemoteServerProfile: typeof saveRemoteServerProfile + markRemoteServerConnected: typeof markRemoteServerConnected + removeRemoteServerProfile: typeof removeRemoteServerProfile recordWorkspaceLaunch: typeof recordWorkspaceLaunch addRecentModelPreference: typeof addRecentModelPreference isFavoriteModelPreference: typeof isFavoriteModelPreference @@ -1080,6 +1180,7 @@ const configContextValue: ConfigContextValue = { selectColorSchemePreset, saveColorSchemePreset, serverSettings, + setListeningMode, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, @@ -1090,12 +1191,16 @@ const configContextValue: ConfigContextValue = { updateSpeechSettings, recentFolders, opencodeBinaries, + remoteServers, uiState, addRecentFolder, removeRecentFolder, renameRecentFolderProject, addOpenCodeBinary, removeOpenCodeBinary, + saveRemoteServerProfile, + markRemoteServerConnected, + removeRemoteServerProfile, recordWorkspaceLaunch, addRecentModelPreference, isFavoriteModelPreference, @@ -1177,6 +1282,7 @@ export { setProviderModelVisibility, getProviderModelVisibilityPreference, providerModelVisibilitySaveFailed, + setListeningMode, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, diff --git a/packages/ui/src/styles/components/remote-access.css b/packages/ui/src/styles/components/remote-access.css new file mode 100644 index 000000000..3e5fa4eb2 --- /dev/null +++ b/packages/ui/src/styles/components/remote-access.css @@ -0,0 +1,346 @@ +.remote-overlay { + position: fixed; + inset: 0; + z-index: 41; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.modal-overlay.remote-overlay-backdrop { + background: var(--overlay-scrim); + backdrop-filter: blur(6px); + z-index: 40; +} + +.remote-panel { + width: min(960px, 100%); + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.remote-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 20px 24px; + border-bottom: 1px solid var(--border-base); +} + +.remote-eyebrow { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 11px; + color: var(--text-subtle); + margin: 0 0 4px; +} + +.remote-title { + margin: 0; + font-size: 20px; + color: var(--text-primary); +} + +.remote-subtitle { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 14px; +} + +.remote-close { + border: 1px solid var(--border-base); + background: var(--surface-secondary); + color: var(--text-primary); + border-radius: 999px; + padding: 6px 10px; + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.remote-body { + padding: 16px 24px 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.remote-section { + border: 1px solid var(--border-base); + border-radius: 12px; + background: var(--surface-secondary); + padding: 16px; +} + +.remote-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.remote-section-title { + display: flex; + gap: 10px; + align-items: center; +} + +.remote-icon { + width: 18px; + height: 18px; +} + +.remote-label { + margin: 0; + color: var(--text-primary); + font-weight: 600; +} + +.remote-help { + margin: 2px 0 0; + color: var(--text-secondary); + font-size: 13px; +} + +.remote-refresh { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--border-base); + background: var(--surface-primary); + color: var(--text-primary); + cursor: pointer; +} + +.remote-refresh-label { + display: inline-block; +} + +@media (max-width: 640px) { + .remote-refresh-label { + display: none; + } +} + +.remote-toggle { + position: relative; + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border-base); + background: var(--surface-primary); + cursor: pointer; +} + +.remote-toggle-switch { + width: 58px; + height: 28px; + border-radius: 999px; + background: var(--surface-secondary); + border: 1px solid var(--border-base); + display: inline-flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 0 6px; + transition: background 0.2s ease, border-color 0.2s ease; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.remote-toggle-state { + pointer-events: none; + white-space: nowrap; +} + +.remote-toggle-thumb { + width: 18px; + height: 18px; + border-radius: 999px; + background: var(--surface-primary); + transition: transform 0.2s ease; + transform: translateX(0); +} + +.remote-toggle-switch[data-checked="true"] { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: var(--surface-primary); +} + +.remote-toggle-switch[data-checked="true"] .remote-toggle-thumb { + transform: translateX(20px); +} + +.remote-toggle-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.remote-toggle-title { + font-weight: 600; + color: var(--text-primary); +} + +.remote-toggle-caption { + font-size: 13px; + color: var(--text-secondary); +} + +.remote-toggle-note { + margin: 12px 0 0; + font-size: 13px; + color: var(--text-secondary); +} + +.remote-address-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.remote-address { + border: 1px solid var(--border-base); + border-radius: 12px; + padding: 12px; + background: var(--surface-primary); +} + +.remote-address-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.remote-address-url { + margin: 0; + font-weight: 600; + color: var(--text-primary); +} + +.remote-address-meta { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 12px; +} + +.remote-actions { + display: flex; + gap: 8px; +} + +.remote-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 999px; + border: 1px solid var(--border-base); + background: var(--surface-secondary); + color: var(--text-primary); + cursor: pointer; +} + +.remote-address-disclosure { + border: 1px solid var(--border-base); + border-radius: 12px; + background: var(--surface-primary); + overflow: hidden; +} + +.remote-address-disclosure-trigger { + width: 100%; + min-height: 40px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + padding: 8px 12px; + border: 0; + background: transparent; + color: var(--text-primary); + cursor: pointer; +} + +.remote-address-disclosure-label { + grid-column: 2; + justify-self: center; + text-align: center; + font-size: 13px; + font-weight: 600; +} + +.remote-address-disclosure-chevron { + grid-column: 3; + justify-self: end; + width: 16px; + height: 16px; + color: var(--text-secondary); +} + +.remote-address-disclosure-content { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 10px 10px; + border-top: 1px solid var(--border-base); +} + +.remote-qr { + margin-top: 12px; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + border: 1px dashed var(--border-base); + border-radius: 10px; + background: var(--surface-secondary); +} + +.remote-qr-img { + width: 160px; + height: 160px; + image-rendering: pixelated; +} + +.remote-card { + border: 1px dashed var(--border-base); + border-radius: 10px; + padding: 12px; + color: var(--text-secondary); +} + +.remote-error { + border: 1px solid var(--border-critical, #e65c5c); + background: color-mix(in srgb, var(--border-critical, #e65c5c) 10%, transparent); + border-radius: 10px; + padding: 12px; + color: var(--text-primary); +} + +.remote-spin { + animation: remote-spin 1s linear infinite; +} + +@keyframes remote-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index ed063fc52..62ece3b34 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -9,6 +9,7 @@ @import "./components/selector.css"; @import "./components/env-vars.css"; @import "./components/directory-browser.css"; +@import "./components/remote-access.css"; @import "./components/remote-control.css"; @import "./components/permission-notification.css"; @import "./components/form-request.css"; diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index f34c15d0b..58f46d46b 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -72,7 +72,15 @@ declare global { setClientStateRestoreEnabled?: (accessToken: string, enabled: boolean) => Promise clearClientState?: (accessToken: string) => Promise - showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }> + showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }> + openRemoteWindow?: (payload: { + id: string + name: string + baseUrl: string + entryUrl?: string + proxySessionId?: string + skipTlsVerify: boolean + }) => Promise<{ ok: boolean }> openPreferences?: (section: SettingsSectionId, context?: { instanceId?: string; location?: LocationRef }) => Promise getPreferencesRequest?: () => Promise getPreferencesSection?: () => Promise From 86e34d8a45caa70992897a226254caac2f0d077d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 5 Sep 2026 20:04:51 +0200 Subject: [PATCH 7/9] fix(remote-control): close safety failures and bound browser network buffering Address the three gatekeeper findings: restrict plaintext development relays to literal IPv4 loopback addresses or explicit localhost/IPv6 loopback; normalize reserved WebSocket close codes for browser and Undici callers; reject frames before the browser network send buffer exceeds 24 MiB. Use one shared close-code helper without changing Durable Object close semantics or the E2EE v2 wire protocol. Teardown rejects pending operations, and a failed mutative request is never automatically replayed. Add real ECDH browser-tunnel regressions for security teardown and a stalled network buffer, installed-Undici close validation, hostname spoofing cases, and shared close-code tests. Include the new tunnel suite in CI. Typechecks, targeted regressions, full server/UI/Electron suites, Worker E2E, packaging checks and the Tauri rerun pass locally. --- .github/workflows/pr-build.yml | 1 + packages/remote-control-protocol/src/index.ts | 1 + .../src/websocket-close.test.ts | 12 +++ .../src/websocket-close.ts | 6 ++ .../src/remote-control/connector-protocol.ts | 4 +- .../src/remote-control/connector.test.ts | 19 +++++ .../server/src/remote-control/connector.ts | 20 ++--- .../ui/src/lib/remote-control/tunnel.test.ts | 80 +++++++++++++++++++ packages/ui/src/lib/remote-control/tunnel.ts | 9 ++- 9 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 packages/remote-control-protocol/src/websocket-close.test.ts create mode 100644 packages/remote-control-protocol/src/websocket-close.ts create mode 100644 packages/ui/src/lib/remote-control/tunnel.test.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 69505bd9c..5be20d64f 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -143,6 +143,7 @@ jobs: packages/ui/src/lib/runtime-env.test.ts packages/ui/src/lib/remote-control/bounded-body.test.ts packages/ui/src/lib/remote-control/event-source.test.ts + packages/ui/src/lib/remote-control/tunnel.test.ts packages/ui/src/lib/remote-control/web-socket.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts diff --git a/packages/remote-control-protocol/src/index.ts b/packages/remote-control-protocol/src/index.ts index dd3b1584e..3db8a63f7 100644 --- a/packages/remote-control-protocol/src/index.ts +++ b/packages/remote-control-protocol/src/index.ts @@ -1,3 +1,4 @@ export * from "./crypto" export * from "./frame-budget" export * from "./messages" +export * from "./websocket-close" diff --git a/packages/remote-control-protocol/src/websocket-close.test.ts b/packages/remote-control-protocol/src/websocket-close.test.ts new file mode 100644 index 000000000..b94edff8b --- /dev/null +++ b/packages/remote-control-protocol/src/websocket-close.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { clientWebSocketCloseCode } from "./websocket-close" + +test("client close codes preserve permitted values and normalize reserved codes", () => { + for (const code of [undefined, 1000, 3000, 4001, 4999]) { + assert.equal(clientWebSocketCloseCode(code), code) + } + for (const code of [1002, 1003, 1008, 1009, 1012, 1013, 5000, 3000.5, NaN]) { + assert.equal(clientWebSocketCloseCode(code), 4000) + } +}) diff --git a/packages/remote-control-protocol/src/websocket-close.ts b/packages/remote-control-protocol/src/websocket-close.ts new file mode 100644 index 000000000..b83da9d72 --- /dev/null +++ b/packages/remote-control-protocol/src/websocket-close.ts @@ -0,0 +1,6 @@ +// Browser and Undici WebSockets cannot send reserved protocol close codes. +// Preserve application codes and use a private-use failure code otherwise. +export function clientWebSocketCloseCode(code: number | undefined): number | undefined { + if (code === undefined || code === 1000) return code + return Number.isInteger(code) && code >= 3000 && code <= 4999 ? code : 4000 +} diff --git a/packages/server/src/remote-control/connector-protocol.ts b/packages/server/src/remote-control/connector-protocol.ts index 5af8fa93b..d7cc73367 100644 --- a/packages/server/src/remote-control/connector-protocol.ts +++ b/packages/server/src/remote-control/connector-protocol.ts @@ -1,4 +1,5 @@ import type { ClientToHostMessage, HeaderEntries, RelayToHostMessage } from "@codenomad/remote-control-protocol" +import { isIP } from "node:net" const RESPONSE_HEADER_BLOCKLIST = new Set(["connection", "content-encoding", "content-length", "set-cookie", "transfer-encoding", "upgrade"]) const REQUEST_HEADER_BLOCKLIST = new Set([ @@ -122,7 +123,8 @@ export function validCloseCode(value: number | undefined): value is number { function isLoopbackHostname(hostname: string): boolean { const normalized = hostname.toLowerCase() - return normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.startsWith("127.") + return normalized === "localhost" || normalized === "::1" || normalized === "[::1]" + || (isIP(normalized) === 4 && normalized.startsWith("127.")) } function validHeaders(value: unknown): value is HeaderEntries { diff --git a/packages/server/src/remote-control/connector.test.ts b/packages/server/src/remote-control/connector.test.ts index 7d31034ed..a350bd2b3 100644 --- a/packages/server/src/remote-control/connector.test.ts +++ b/packages/server/src/remote-control/connector.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict" import test from "node:test" +import { clientWebSocketCloseCode } from "@codenomad/remote-control-protocol" +import { WebSocket } from "undici" import { normalizedRelayUrl } from "./connector" import { allowedRemotePath, localHeaders, parseClientMessage, parseRelayMessage } from "./connector-protocol" @@ -9,6 +11,11 @@ test("Remote Control relays require HTTPS except for loopback development", () = assert.equal(normalizedRelayUrl("http://localhost:8787").href, "http://localhost:8787/") assert.throws(() => normalizedRelayUrl("http://relay.example"), /must use HTTPS/) assert.throws(() => normalizedRelayUrl("file:///relay"), /must use HTTPS/) + for (const host of ["127.attacker.example", "127.0.0.1.attacker.example", "128.0.0.1"]) { + assert.throws(() => normalizedRelayUrl(`http://${host}`), /must use HTTPS/) + } + assert.equal(normalizedRelayUrl("http://127.1.2.3:8787").hostname, "127.1.2.3") + assert.equal(normalizedRelayUrl("http://[::1]:8787").hostname, "[::1]") }) test("Remote Control replaces remote credentials and forwarding metadata with host-local identity", () => { @@ -42,6 +49,18 @@ test("Remote Control reaches only API and workspace namespaces", () => { assert.equal(allowedRemotePath("//attacker.example/api/events"), false) }) +test("Undici accepts normalized security and reconnect close codes", async () => { + for (const code of [1002, 1003, 1008, 1009, 1012, 1013]) { + const socket = new WebSocket("ws://127.0.0.1:1") + const finished = new Promise((resolve) => { + socket.addEventListener("error", () => resolve(), { once: true }) + socket.addEventListener("close", () => resolve(), { once: true }) + }) + assert.doesNotThrow(() => socket.close(clientWebSocketCloseCode(code), "Security teardown")) + await finished + } +}) + test("Remote Control accepts only bounded UUID-addressed tunnel contracts", () => { const id = "123e4567-e89b-42d3-a456-426614174000" assert.ok(parseRelayMessage(JSON.stringify({ type: "tunnel.open", id }))) diff --git a/packages/server/src/remote-control/connector.ts b/packages/server/src/remote-control/connector.ts index fbc942d63..6ca0834e8 100644 --- a/packages/server/src/remote-control/connector.ts +++ b/packages/server/src/remote-control/connector.ts @@ -1,4 +1,5 @@ import { + clientWebSocketCloseCode, REMOTE_CONTROL_HEARTBEAT_REQUEST, REMOTE_CONTROL_HEARTBEAT_RESPONSE, REMOTE_CONTROL_MAX_HANDSHAKE_BYTES, @@ -27,7 +28,6 @@ import { parseRelayMessage, relaySocketUrl, responseHeaders, - validCloseCode, } from "./connector-protocol" export { normalizedRelayUrl } from "./connector-protocol" @@ -127,7 +127,7 @@ export class RemoteControlConnector { socket.addEventListener("open", () => { if (this.socket !== socket) return this.sendRelay({ type: "ready", protocol: REMOTE_CONTROL_PROTOCOL_VERSION }) - this.handshakeTimer = setTimeout(() => socket.close(1002, "Remote Control relay handshake timed out"), RELAY_HANDSHAKE_TIMEOUT_MS) + this.handshakeTimer = setTimeout(() => socket.close(clientWebSocketCloseCode(1002), "Remote Control relay handshake timed out"), RELAY_HANDSHAKE_TIMEOUT_MS) this.handshakeTimer.unref() }) socket.addEventListener("message", (event) => this.onRelayMessage(socket, event.data)) @@ -162,7 +162,7 @@ export class RemoteControlConnector { if (this.socket !== socket) return const byteLength = typeof data === "string" ? data.length : data instanceof ArrayBuffer ? data.byteLength : 0 if (byteLength > MAX_RELAY_MESSAGE_BYTES) { - socket.close(1009, "Remote Control relay message is too large") + socket.close(clientWebSocketCloseCode(1009), "Remote Control relay message is too large") return } const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? new TextDecoder().decode(data) : "" @@ -173,12 +173,12 @@ export class RemoteControlConnector { } const message = parseRelayMessage(text) if (!message) { - this.socket?.close(1003, "Invalid Remote Control relay message") + this.socket?.close(clientWebSocketCloseCode(1003), "Invalid Remote Control relay message") return } if (message.type === "ready") { if (message.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) { - this.socket?.close(1002, "Unsupported Remote Control protocol") + this.socket?.close(clientWebSocketCloseCode(1002), "Unsupported Remote Control protocol") return } if (this.handshakeTimer) clearTimeout(this.handshakeTimer) @@ -190,7 +190,7 @@ export class RemoteControlConnector { return } if (!this.ready) { - this.socket?.close(1002, "Remote Control relay handshake required") + this.socket?.close(clientWebSocketCloseCode(1002), "Remote Control relay handshake required") return } if (message.type === "tunnel.open") { @@ -408,7 +408,7 @@ export class RemoteControlConnector { const socket = tunnel.localSockets.get(id) tunnel.localSockets.delete(id) tunnel.localSocketQueues.delete(id) - socket?.close(validCloseCode(code) ? code : undefined, boundedCloseReason(reason)) + socket?.close(clientWebSocketCloseCode(code), boundedCloseReason(reason)) } private sendClient(tunnelId: string, message: HostToClientMessage): Promise { @@ -449,7 +449,7 @@ export class RemoteControlConnector { this.tunnels.delete(id) for (const controller of tunnel.httpRequests.values()) controller.abort() for (const socket of tunnel.localSockets.values()) { - socket.close(validCloseCode(code) ? code : undefined, boundedCloseReason(reason)) + socket.close(clientWebSocketCloseCode(code), boundedCloseReason(reason)) } tunnel.httpRequests.clear() tunnel.localSockets.clear() @@ -465,7 +465,7 @@ export class RemoteControlConnector { if (socket?.readyState !== WebSocket.OPEN) return const payload = JSON.stringify(message) if (socket.bufferedAmount + payload.length > MAX_RELAY_BUFFERED_BYTES) { - socket.close(1013, "Remote Control relay send buffer exceeded its safety limit") + socket.close(clientWebSocketCloseCode(1013), "Remote Control relay send buffer exceeded its safety limit") this.onClosed(socket, "Remote Control relay send buffer exceeded its safety limit") return } @@ -479,7 +479,7 @@ export class RemoteControlConnector { const socket = this.socket if (!this.ready || socket?.readyState !== WebSocket.OPEN) return if (Date.now() - this.lastHeartbeatAt > HEARTBEAT_TIMEOUT_MS) { - socket.close(1012, "Remote Control relay heartbeat timed out") + socket.close(clientWebSocketCloseCode(1012), "Remote Control relay heartbeat timed out") this.onClosed(socket, "Remote Control relay stopped responding") return } diff --git a/packages/ui/src/lib/remote-control/tunnel.test.ts b/packages/ui/src/lib/remote-control/tunnel.test.ts new file mode 100644 index 000000000..65504037a --- /dev/null +++ b/packages/ui/src/lib/remote-control/tunnel.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createHostHandshake } from "@codenomad/remote-control-protocol" +import { RemoteControlTunnel } from "./tunnel" + +async function fixture(t: test.TestContext) { + const keys = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", keys.privateKey)) + let socket!: StrictSocket + let sent!: () => void + const requestSent = new Promise((resolve) => { sent = resolve }) + class StrictSocket extends EventTarget { + static OPEN = 1 + static CLOSING = 2 + readyState = 1 + bufferedAmount = 0 + binaryType = "" + closedWith?: number + encryptedSends = 0 + constructor(_url: URL) { + super() + socket = this + queueMicrotask(() => this.dispatchEvent(new Event("open"))) + } + send(data: string | Uint8Array) { + if (typeof data === "string") { + void host.accept(data).then(({ ready }) => { + this.dispatchEvent(new MessageEvent("message", { data: ready })) + }) + } else { + this.encryptedSends += 1 + sent() + } + } + close(code?: number) { + if (code !== undefined && code !== 1000 && !(Number.isInteger(code) && code >= 3000 && code <= 4999)) { + throw new DOMException("Invalid close code", "InvalidAccessError") + } + this.closedWith = code + this.readyState = 3 + queueMicrotask(() => this.dispatchEvent(new Event("close"))) + } + } + const oldWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + const oldSocket = Object.getOwnPropertyDescriptor(globalThis, "WebSocket") + Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { href: "https://relay.example/" } } }) + Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: StrictSocket }) + t.after(() => { + if (oldWindow) Object.defineProperty(globalThis, "window", oldWindow) + else Reflect.deleteProperty(globalThis, "window") + if (oldSocket) Object.defineProperty(globalThis, "WebSocket", oldSocket) + else Reflect.deleteProperty(globalThis, "WebSocket") + }) + const tunnel = new RemoteControlTunnel( + { tunnelPath: "/__codenomad/tunnel" }, + await crypto.subtle.exportKey("jwk", keys.publicKey), + StrictSocket as unknown as typeof WebSocket, + ) + return { tunnel, requestSent, socket: () => socket } +} + +test("security close uses a permitted code and rejects pending HTTP immediately", async (t) => { + const f = await fixture(t) + const rejected = assert.rejects(f.tunnel.fetch("/api/events"), /tunnel disconnected/) + await f.requestSent + f.socket().dispatchEvent(new MessageEvent("message", { data: "unexpected plaintext" })) + await rejected + assert.equal(f.socket().closedWith, 4000) +}) + +test("a stalled browser network buffer closes the tunnel without sending or replaying the next request", async (t) => { + const f = await fixture(t) + const rejected = assert.rejects(f.tunnel.fetch("/api/events"), /tunnel disconnected/) + await f.requestSent + f.socket().bufferedAmount = 24 * 1024 * 1024 + await assert.rejects(f.tunnel.fetch("/api/items", { method: "POST", body: "mutation" }), /tunnel disconnected/) + await rejected + assert.equal(f.socket().encryptedSends, 1) + assert.equal(f.socket().closedWith, 4000) +}) diff --git a/packages/ui/src/lib/remote-control/tunnel.ts b/packages/ui/src/lib/remote-control/tunnel.ts index 00fb2c267..903c6782c 100644 --- a/packages/ui/src/lib/remote-control/tunnel.ts +++ b/packages/ui/src/lib/remote-control/tunnel.ts @@ -1,4 +1,5 @@ import { + clientWebSocketCloseCode, createClientHandshake, decodeBase64, encodeBase64, @@ -86,7 +87,7 @@ async function discoverRemoteControl(): Promise { } } -class RemoteControlTunnel implements RemoteSocketBridge { +export class RemoteControlTunnel implements RemoteSocketBridge { private socket: WebSocket | null = null private channel: EncryptedChannel | null = null private connection: Promise | null = null @@ -325,6 +326,10 @@ class RemoteControlTunnel implements RemoteSocketBridge { if (this.channel !== channel || this.socket !== socket || socket.readyState !== WebSocket.OPEN) { throw new Error("Remote Control tunnel is disconnected") } + if (socket.bufferedAmount + frame.byteLength > MAX_PENDING_FRAME_BYTES) { + this.close(1009, "Remote Control network send buffer exceeded its safety limit") + throw new Error("Remote Control network send buffer exceeded its safety limit") + } socket.send(frame) }).finally(release) this.sendQueue = send.catch(() => undefined) @@ -448,7 +453,7 @@ class RemoteControlTunnel implements RemoteSocketBridge { const socket = this.socket this.socket = null this.channel = null - if (socket && code && socket.readyState < WebSocket.CLOSING) socket.close(code, reason) + if (socket && socket.readyState < WebSocket.CLOSING) socket.close(clientWebSocketCloseCode(code), reason) for (const id of Array.from(this.pendingHttp.keys())) this.failHttp(id, new Error("Remote Control tunnel disconnected")) for (const id of Array.from(this.pendingSockets.keys())) this.finishSocket(id, 1006, "Remote Control tunnel disconnected", false) } From 0af3b5fa5a92943b94760fb69d9123885357db7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 19 Sep 2026 22:34:18 +0200 Subject: [PATCH 8/9] fix(ci): prepare relay protocol for pruning UI The native pruning matrix intentionally installs dependencies with lifecycle scripts disabled, but its UI fixture imports the Remote Control tunnel through the protocol workspace's dist entry. Node 24 no longer left that generated entry available implicitly, so Vite failed dependency scanning and every platform timed out before exercising pruning UI behavior. Build the trusted Remote Control protocol workspace explicitly after npm ci while preserving --ignore-scripts for the rest of the installation. This keeps the fixture's package contract identical to production without weakening the native plugin isolation checks. Validated the workflow YAML and diff, rebuilt the protocol from an absent dist directory under Node 24.20.0, typechecked it, and completed the full Vite UI production build. --- .github/workflows/pr-build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index c098c3be7..eb59ac8a3 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -82,6 +82,8 @@ jobs: node-version-file: .node-version cache: npm - run: npm ci --ignore-scripts + - name: Build Remote Control protocol for UI checks + run: npm run build --workspace @codenomad/remote-control-protocol - name: Install browser for native pruning UI checks run: npx --workspace @codenomad/ui playwright install --with-deps chromium - name: Pack and install outside the checkout From 7d06a2d574c8825a509cbcf9da31a4d1bb5ae76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 20 Sep 2026 00:28:57 +0200 Subject: [PATCH 9/9] fix(packaging): materialize workspace dependencies npm installs internal workspaces as links, and Node's recursive copy preserves those nested links on POSIX even when dereference is requested. Electron archives then contain a link back into the checkout while Tauri rejects the resource before bundling. Replace each production workspace link with its stripped prebuilt package after dependency staging. Extend packaged-resource smoke checks to require a physical Remote Control protocol package and import it through the same ESM loader as the server entrypoint. Cover link replacement on Windows and POSIX, including preservation of the original link target. Validated both desktop preparation paths on Node 24.20.0 and a native Linux staging/copy/import flow. --- scripts/desktop-server-resources.cjs | 28 +++++++++++++++++++++ scripts/desktop-server-resources.test.cjs | 30 +++++++++++++++++++++++ scripts/smoke-packaged-resources.cjs | 13 ++++++++-- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/scripts/desktop-server-resources.cjs b/scripts/desktop-server-resources.cjs index 3d8b4374a..2beeafc70 100644 --- a/scripts/desktop-server-resources.cjs +++ b/scripts/desktop-server-resources.cjs @@ -80,6 +80,23 @@ function stagePrebuiltWorkspacePackage(source, destination) { fs.cpSync(sourceDist, path.join(destination, "dist"), { recursive: true }) } +function materializePrebuiltWorkspacePackage(source, nodeModulesRoot) { + const manifest = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8")) + const packageName = manifest.name + const packageParts = typeof packageName === "string" ? packageName.split("/") : [] + const validShape = packageParts.length === 1 + ? !packageParts[0].startsWith("@") + : packageParts.length === 2 && packageParts[0].startsWith("@") + if (!validShape || packageParts.some((part) => !part || part === "." || part === ".." || part.includes("\\"))) { + throw new Error(`Invalid workspace package name in ${source}`) + } + + const destination = path.join(nodeModulesRoot, ...packageParts) + fs.rmSync(destination, { recursive: true, force: true }) + stagePrebuiltWorkspacePackage(source, destination) + return destination +} + function stagePackagedServer(options) { const { workspaceRoot, serverRoot, log = () => {}, env = process.env } = options const npmTarget = resolveNpmTarget(options.target || env.CODENOMAD_NODE_TARGET) @@ -88,6 +105,7 @@ function stagePackagedServer(options) { const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-server-")) const stagedServerRoot = path.join(stagingRoot, "packages", "server") + const workspacePackageSources = [] try { fs.mkdirSync(stagedServerRoot, { recursive: true }) fs.copyFileSync(path.join(workspaceRoot, "package.json"), path.join(stagingRoot, "package.json")) @@ -98,6 +116,7 @@ function stagePackagedServer(options) { const source = path.join(workspaceRoot, packagePath) const destination = path.join(stagingRoot, packagePath) stagePrebuiltWorkspacePackage(source, destination) + workspacePackageSources.push(source) } log(`installing production server dependencies from the workspace lock for ${npmTarget.target}`) @@ -129,6 +148,14 @@ function stagePackagedServer(options) { fs.rmSync(path.join(rootModules, "@neuralnomads", "codenomad"), { recursive: true, force: true }) fs.cpSync(rootModules, serverModules, { recursive: true, dereference: true }) if (fs.existsSync(serverOverrides)) fs.cpSync(serverOverrides, serverModules, { recursive: true, dereference: true }) + // Recursive copies retain nested npm workspace links on POSIX. Replace them + // explicitly so desktop packagers never receive links back into the checkout. + for (const source of workspacePackageSources) { + materializePrebuiltWorkspacePackage(source, serverModules) + } + if (workspacePackageSources.length > 0) { + log(`materialized ${workspacePackageSources.length} prebuilt workspace package(s)`) + } for (const artifact of ["public", "dist"]) { fs.cpSync(path.join(serverRoot, artifact), path.join(stagedServerRoot, artifact), { recursive: true }) } @@ -345,6 +372,7 @@ function pruneKnownServerDependencies(root, log) { module.exports = { copyPackagedServerResources, + materializePrebuiltWorkspacePackage, resolveNpmTarget, stagePrebuiltWorkspacePackage, stagePackagedServer, diff --git a/scripts/desktop-server-resources.test.cjs b/scripts/desktop-server-resources.test.cjs index 9ada5cc54..fb21e8ebe 100644 --- a/scripts/desktop-server-resources.test.cjs +++ b/scripts/desktop-server-resources.test.cjs @@ -4,6 +4,7 @@ const os = require("node:os") const path = require("node:path") const test = require("node:test") const { + materializePrebuiltWorkspacePackage, resolveNpmTarget, stagePrebuiltWorkspacePackage, validateServerProductionLock, @@ -55,6 +56,35 @@ test("stages prebuilt workspace packages without install lifecycle scripts", (t) assert.equal(fs.readFileSync(path.join(destination, "dist", "index.js"), "utf8"), "export {}\n") }) +test("materializes workspace packages instead of retaining install links", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-workspace-materialize-")) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const source = path.join(root, "source") + const linkedSource = path.join(root, "linked-source") + const nodeModules = path.join(root, "node_modules") + const destination = path.join(nodeModules, "@codenomad", "example") + fs.mkdirSync(path.join(source, "dist"), { recursive: true }) + fs.mkdirSync(linkedSource, { recursive: true }) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.writeFileSync(path.join(source, "package.json"), JSON.stringify({ + name: "@codenomad/example", + version: "1.0.0", + scripts: { prepare: "npm run build" }, + })) + fs.writeFileSync(path.join(source, "dist", "index.js"), "export const value = 'packaged'\n") + fs.writeFileSync(path.join(linkedSource, "sentinel"), "keep\n") + fs.symlinkSync(linkedSource, destination, process.platform === "win32" ? "junction" : "dir") + + assert.equal(fs.lstatSync(destination).isSymbolicLink(), true) + assert.equal(materializePrebuiltWorkspacePackage(source, nodeModules), destination) + + assert.equal(fs.lstatSync(destination).isSymbolicLink(), false) + assert.equal(fs.readFileSync(path.join(destination, "dist", "index.js"), "utf8"), "export const value = 'packaged'\n") + assert.equal(fs.readFileSync(path.join(linkedSource, "sentinel"), "utf8"), "keep\n") + const manifest = JSON.parse(fs.readFileSync(path.join(destination, "package.json"), "utf8")) + assert.equal(manifest.scripts, undefined) +}) + test("rejects an unpinned production dependency despite an otherwise valid lock", () => { const root = path.resolve(__dirname, "..") const lock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8")) diff --git a/scripts/smoke-packaged-resources.cjs b/scripts/smoke-packaged-resources.cjs index a1aa97818..5ed30ec1c 100644 --- a/scripts/smoke-packaged-resources.cjs +++ b/scripts/smoke-packaged-resources.cjs @@ -2,9 +2,11 @@ const fs = require("fs") const path = require("path") const { spawnSync } = require("child_process") +const { pathToFileURL } = require("url") const { MANAGED_NODE_VERSION } = require("./prepare-node-runtime.cjs") const requiredPackages = [ + "@codenomad/remote-control-protocol", "yaml", "fastify", "@fastify/static", @@ -17,6 +19,7 @@ const requiredPackages = [ "zod", "node-forge", ] +const materializedWorkspacePackages = new Set(["@codenomad/remote-control-protocol"]) function parseArgs(argv) { const options = {} @@ -75,6 +78,9 @@ function smokeServer(resourcesRoot, target) { for (const packageName of requiredPackages) { const packageRoot = path.join(serverRoot, "node_modules", ...packageName.split("/")) if (!fs.existsSync(packageRoot)) throw new Error(`Missing packaged dependency: ${packageName}`) + if (materializedWorkspacePackages.has(packageName) && fs.lstatSync(packageRoot).isSymbolicLink()) { + throw new Error(`Packaged workspace dependency is still a symbolic link: ${packageName}`) + } } console.log(`packaged server static checks ok for ${target}`) @@ -95,9 +101,12 @@ function smokeServer(resourcesRoot, target) { `for (const name of ${JSON.stringify(requiredPackages)}) await import(name);`, "console.log('packaged dependency imports ok');", ].join(" ") + const loaderFileUrl = pathToFileURL(path.join(serverRoot, "dist", "loader.js")).href + const registerScript = `import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(${JSON.stringify(loaderFileUrl)}, pathToFileURL("./"));` + const loaderArg = `data:text/javascript,${encodeURIComponent(registerScript)}` - // Resolve from the packaged server, not the build checkout. The V2 client is ESM-only. - run(node, ["--input-type=module", "-e", importScript], { cwd: serverRoot }) + // Resolve from the packaged server with the same loader used by its entrypoint. + run(node, ["--import", loaderArg, "--input-type=module", "-e", importScript], { cwd: serverRoot }) } function smokeLoadingAssets(loadingRoot) {