diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 1d4ab8db..6ea701dd 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -104,16 +104,20 @@ jobs: - name: Test changed runnable UI behavior run: >- node --import tsx --test + packages/ui/src/components/browser-frame-security.test.ts packages/ui/src/components/message-timeline-v2.test.ts packages/ui/src/components/provider-auth/provider-options.test.ts packages/ui/src/components/session/session-bottom-pin-intent.test.ts + packages/ui/src/components/session/session-idle-attention.test.ts packages/ui/src/components/session-list-visibility.test.ts packages/ui/src/components/unified-picker-path.test.ts packages/ui/src/components/virtual-follow-behavior.test.ts + packages/ui/src/lib/client-identity.test.ts packages/ui/src/lib/filesystem-events.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/hooks/use-instance-metadata.test.ts packages/ui/src/lib/hooks/use-foreground-refresh.test.ts + packages/ui/src/lib/hooks/use-electron-folder-launch.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts packages/ui/src/lib/model-visibility.test.ts @@ -126,7 +130,9 @@ jobs: packages/ui/src/stores/app-session-snapshot-merge.test.ts packages/ui/src/stores/restore-workspace-commit-gates.test.ts packages/ui/src/stores/client-state-codec.test.ts + packages/ui/src/stores/client-state-partitions.test.ts packages/ui/src/stores/client-state.test.ts + packages/ui/src/stores/message-prompt-display.test.ts packages/ui/src/stores/message-v2/instance-store.test.ts packages/ui/src/stores/message-v2/message-hydration-authority.test.ts packages/ui/src/stores/message-v2/message-status.test.ts @@ -145,6 +151,7 @@ jobs: packages/ui/src/components/form-request-tool-target.test.ts packages/ui/src/components/form-request.test.ts packages/ui/src/lib/hooks/use-active-session-message-load.test.ts + packages/ui/src/stores/app-tabs.test.ts packages/ui/src/stores/forms.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/opencode-data.test.ts diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index e92791af..c8fa105c 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -18,11 +18,13 @@ description: | - The only OpenCode client dependency is the experimental `@opencode-ai/client` protocol. Server and UI stay on the same reviewed release; the selected runtime CLI is independently updated and validated through service/API compatibility rather than an exact version gate. Current public `@opencode-ai/sdk` docs describe a different contract. - Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode-ai/client` declarations. - There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging. -- The server owns one shared OpenCode service through `OpenCodeSharedService` and its lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Proven host shutdown delegates to native `Service.stop`; WSL uses native authenticated health stop to avoid the client's cross-namespace PID fallback. Workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. +- The server uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global OpenCode daemon. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and uses no cross-namespace PID operations. - The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. - OpenCode owns session APIs, session Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), location-scoped background Shells, and interactive PTYs. The Status panel lists `client.shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Interactive `client.pty.*` terminals remain separate. -- CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. -- V2 service startup forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; never share the V1 database with V2. +- CodeNomad owns explicit Stop Workspace eviction, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. Tab/window close only detaches local UI and never evicts. +- OpenCode owns the global daemon's standard state and database. Allowed configured environment variables apply only to `service start` for a missing daemon; an existing daemon is unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. +- Native desktop identity is channel plus config profile: one singleton process/backend per profile, multiple UUID windows, second-launch focus by default, and `--new-window` for another window. Stable/dev/non-default profiles isolate native state; OpenCode sessions/messages are shared while tabs/drafts/views are per-window. +- Client-state V3 is a per-window envelope over the V2 content-addressed partition graph with atomic publication/migration, ownership-fenced writes, and conservative post-commit GC. Native SideCar/browser previews are sandboxed without same-origin access; DOM comment inspection is web-only. ## Package Map @@ -59,7 +61,7 @@ description: | | Avoid | Use | |---|---| | Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client` declarations | -| One `opencode serve` per workspace | One CodeNomad-managed shared service | +| One `opencode serve` per workspace | One externally owned global daemon through the official CLI lifecycle | | Per-worktree clients/processes | Root proxy client plus native location/directory inputs | | Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Native session Shell/instructions, background `shell.*`, and separate interactive `pty.*` management | | OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers | diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index b0c0486c..cc8c0374 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -10,7 +10,11 @@ Electron/Tauri -> CodeNomad Fastify server -> one shared OpenCode service SolidJS UI <- /api/events <- event bridge ``` -The server uses `packages/server/src/workspaces/opencode-service.ts` for a lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Transferable lease proof binds the registration and endpoint credentials to the daemon PID/process-start identity, host or WSL namespace, and launch signature. Proven host shutdown delegates to native `Service.stop`; WSL uses native authenticated health stop to avoid the client's cross-namespace PID fallback. `WorkspaceManager` validates each selected directory with `client.location.get()` and stores its `LocationRef`; a workspace is a logical location owner, not an OpenCode child process. +The server uses `packages/server/src/workspaces/opencode-service.ts` and the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global daemon. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and performs no cross-namespace PID operations. `WorkspaceManager` validates each selected directory with `client.location.get()` and stores its `LocationRef`; explicit Stop Workspace evicts that location, while tab/window close only detaches local UI. + +Native desktop identity is channel plus config profile: one singleton process/backend per profile, multiple UUID windows, MRU focus on second launch, and `--new-window` for another window. Stable/dev/non-default profiles isolate native/browser/client state. OpenCode sessions/messages remain shared; tabs, drafts, views, and restore membership are per-window. + +Client-state V3 is a per-window envelope over the V2 content-addressed partition graph. Electron and Tauri prepare immutable partitions before atomically publishing the root, fence migration and writes on current ownership, and collect only unreferenced partitions after publication. Native SideCar/browser previews omit same-origin sandbox permission, making DOM comment inspection web-only. ## Boundaries @@ -36,7 +40,7 @@ Session Shell remains separate from background Shell and PTY management. The Sta OpenCode location/workspace identity is upstream state. CodeNomad persists only its own preferences and policy metadata, including Yolo state. -OpenCode V2 forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; V1 and V2 databases must remain separate. +OpenCode owns the global daemon's standard state and database. Allowed configured environment variables apply only when CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy `OPENCODE_DB`/`XDG_STATE_HOME` ownership variables are ignored. ## Entry Points diff --git a/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md index 4ba27754..1ebffb2b 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md @@ -6,6 +6,14 @@ CodeNomad supports two desktop platforms: - **Electron** (primary, mature) - **Tauri** (emerging, Rust-based) +## Process, Window, And State Model + +- Identity scope is update channel plus config profile. Each scope has one native singleton process and one CodeNomad backend; stable, dev, and non-default config profiles isolate native/browser/client state. +- A second launch focuses the MRU window unless `--new-window` requests another UUID-backed local window. +- OpenCode sessions/messages stay in the shared global daemon. Tabs, drafts, views, restore membership, and native bounds are per-window. +- Client-state V3 is a per-window envelope over the V2 SHA-256 content-addressed partition graph. Prepare immutable partitions, fence migration and writes on current ownership/renderer authority, atomically publish the root, then conservatively remove only partitions unreferenced by all windows. +- Native SideCar/browser previews are sandboxed without `allow-same-origin`; DOM comment inspection is web-only. + ## Electron ### Directory Structure diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 61371ba0..cb006242 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -4,10 +4,10 @@ 1. UI posts a folder to `/api/workspaces`. 2. `WorkspaceManager` resolves the binary launch spec and calls the single `OpenCodeSharedService`. -3. The CodeNomad adapter discovers or launches one shared `opencode serve --service` endpoint under a lifecycle lock and records transferable registration, endpoint, launch-signature, PID, process-start, and namespace proof. +3. The adapter runs the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle and validates the authenticated loopback endpoint. WSL requires Windows localhost forwarding and stays inside the Linux namespace. 4. `client.location.get` validates the directory and returns native location/workspace identity. 5. CodeNomad publishes workspace events on `/api/events` and exposes `/workspaces/:id/instance` as the authorized native API proxy. -6. Final-owner deletion queues location eviction. Proven final shared-service shutdown flushes queued evictions and sends an authenticated stop only if no live CodeNomad peer remains and the exact daemon identity still matches. +6. Explicit Stop Workspace evicts the location/resources and removes the logical workspace. Ordinary tab/window close only detaches local UI; backend shutdown clears connection state. Neither close nor shutdown stops the daemon. ## Prompt, Shell, Instructions, And PTYs diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md index c4ec7bbc..35a26c29 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -5,7 +5,7 @@ CodeNomad keeps the experimental `@opencode-ai/client` protocol aligned in `packages/server/package.json` and `packages/ui/package.json`. The runtime CLI is independently updated and startup validates service health and API compatibility without requiring that exact dependency version. This is distinct from the current public `@opencode-ai/sdk` documentation. - Promise client: `import { OpenCode } from "@opencode-ai/client"` -- Service lifecycle: `import { Service } from "@opencode-ai/client/service"` +- Service authentication headers: `import { Service } from "@opencode-ai/client/service"` - Client construction: `OpenCode.make({ baseUrl, headers?, fetch? })` - Declarations: `node_modules/@opencode-ai/client/dist/promise/` @@ -15,7 +15,7 @@ Do not import `@opencode-ai/sdk`; its wrapper shapes, `{ data, error }` conventi | Area | Calls | CodeNomad caller | |---|---|---| -| Service | `Service.discover/headers`; custom launch and authenticated stop | `packages/server/src/workspaces/opencode-service.ts` | +| Service | CLI `service status/start/get password`; `Service.headers` for authenticated health/API calls | `packages/server/src/workspaces/{host,wsl,opencode-cli,opencode}-service.ts` | | Location | `client.location.get`, `client.debug.location.evict` | shared service wrapper | | Events | `client.event.subscribe()` | `packages/server/src/workspaces/instance-events.ts` | | Sessions | `list/get/create/fork/remove/rename/prompt/command/shell/interrupt` | UI session stores | diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index fd1cc905..73be6a14 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -12,14 +12,14 @@ - A CodeNomad workspace must validate through `client.location.get` before becoming ready. - Directory-bearing proxy input is untrusted and must resolve to the workspace root or one of its Git worktrees. - Session ID alone is insufficient: the proxy fetches the session and verifies `session.location.directory`. -- Queue eviction after the final logical owner is deleted; flush it only during proven final shared-service shutdown. +- Explicit Stop Workspace evicts the native location/resources and removes CodeNomad's logical workspace. Ordinary tab/window close only detaches local UI and never evicts. ## Shared Lifecycle -- There is one shared service, client and upstream event subscription. CodeNomad keeps lease and process-identity proof around lifecycle operations, delegates proven host shutdown to native `Service.stop`, and uses native authenticated health stop for WSL. -- A workspace stop removes location ownership; it does not stop a dedicated OpenCode process. -- Transferable proof records registration/credentials, daemon PID and process-start identity, host/WSL namespace, and launch signature. Shutdown stops only after no live peer remains and the proof still identifies the exact daemon. -- V2 forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; V1/V2 schemas must not share a database. +- There is one externally owned global service, one server client and one upstream event subscription. CodeNomad uses official host/WSL CLI status/start/password commands, owns no private service state or PID, and never stops the daemon on backend shutdown. +- WSL requires Windows localhost forwarding, executes lifecycle commands inside Linux, and never uses cross-namespace PID operations. +- A workspace stop evicts its location; it does not stop a dedicated process or the global daemon. +- OpenCode owns standard state/database. Allowed configured environment variables apply only when starting a missing daemon; existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` are ignored. - The native event stream is volatile. Reconnect must reconcile authoritative state; use current `session.*`, `filesystem.changed`, and `config.updated` names rather than obsolete event aliases. ## Ownership Matrix @@ -29,7 +29,7 @@ | Session/message/Shell/instructions | OpenCode native API; session Shell remains separate from background Shell and PTY management | | Background Shell list/metadata/output/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on Shell events/reconnect | | Interactive PTYs | Separate native `pty.*` API | -| Service discovery/start/stop | CodeNomad hardened adapter using selected OpenCode primitives | +| Service status/start/password | CodeNomad adapter using the selected host or WSL CLI; daemon stop remains external | | Workspace and directory authorization | CodeNomad | | Git status/diff and mutations | CodeNomad | | Yolo policy/persistence/auto-reply | CodeNomad | diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index f04daada..ee710398 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -2,15 +2,13 @@ ## Shared Service -`WorkspaceManager` owns one `OpenCodeSharedService`. Production discovers an existing endpoint or launches one with CodeNomad's detached launcher. The wrapper creates one server-side Promise client, performs health checks, invalidates failed connections, and calls native `Service.stop` for a proven host daemon. WSL uses native authenticated health stop so no Windows PID fallback can run. +`WorkspaceManager` owns one `OpenCodeSharedService`. Production runs the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle, validates the authenticated loopback endpoint, creates one Promise client, and invalidates failed connections. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and performs no cross-namespace PID operations. -Lifecycle leases serialize processes and carry transferable proof: registration and endpoint credentials, daemon PID/process-start identity, host/WSL namespace, and launch signature. A peer can inherit proof, but only the final verified process may send the authenticated stop and wait for that daemon to exit. - -V2 forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`. Never point V1 and V2 at the same database. The configured/inherited environment is part of the launch signature and applies when the service starts/restarts. +OpenCode owns standard state/database. Allowed configured environment variables apply only to `service start` for a missing daemon; existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` ownership variables are ignored. ## Locations And Directories -Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory/workspace ID. Final-owner deletion queues `client.debug.location.evict`; proven final shared-service shutdown flushes it after excluding live peers. +Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory/workspace ID. Explicit Stop Workspace calls `client.debug.location.evict` before removing the logical workspace. Ordinary tab/window close only detaches local UI and never evicts. The instance proxy is method/path allowlisted, rejects unowned paths, `directory`, `location.directory`, and `location[directory]` values, and verifies session location before forwarding. Keep this check at the server trust boundary; new upstream routes require explicit review. diff --git a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md index 3e6c2a97..daa5157a 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md @@ -10,10 +10,11 @@ ## OpenCode Service - Use `OpenCodeSharedService` in `packages/server/src/workspaces/opencode-service.ts`. -- Keep one CodeNomad-managed shared-service lifecycle and one event subscription for all workspaces. Production lifecycle is custom and process-proofed; do not replace it with direct `Service.ensure`/`Service.stop`. +- Keep one shared-service adapter and one event subscription for all workspaces. Use the selected host or WSL CLI's official status/start/password lifecycle, own no private service state/PID, and never stop the externally owned global daemon on backend shutdown. - Model workspaces with native `LocationRef`/directories in `packages/server/src/workspaces/manager.ts`. - Never spawn or stop OpenCode per workspace and never add plugin installation/packaging. -- Force V2 `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db` and never share the V1 database with V2. +- Explicit Stop Workspace evicts the location; ordinary UI close never calls workspace deletion. WSL requires localhost forwarding and no cross-namespace PID operations. +- Leave global service state/database ownership to OpenCode. Pass allowed environment only when starting a missing daemon; leave an existing daemon unchanged and ignore `OPENCODE_DB`/`XDG_STATE_HOME`. ## Trust Boundaries diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce813069..6e6d1509 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,14 +113,19 @@ Then open a pull request on GitHub targeting the `dev` branch. - Server and UI must use the same reviewed `@opencode-ai/client` version. The selected `opencode2` CLI is updated independently and validated through service health and API compatibility; startup must not reject it solely for a different version string. Review OpenCode release notes, current documentation, and installed declarations on every upgrade; this is not the public `@opencode-ai/sdk` contract. - Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode documentation](https://opencode.ai/docs/), and `node_modules/@opencode-ai/client/dist/promise/`. -- `packages/server/src/workspaces/opencode-service.ts` owns a custom lease-locked discovery, launch, process-proof, and authenticated-stop lifecycle. Production does not call `Service.ensure` or `Service.stop` directly. Workspaces are native OpenCode locations/directories, not separate server processes. -- V2 always uses `~/.local/share/opencode2/opencode.db`. Never reuse the V1 database for V2. +- `packages/server/src/workspaces/opencode-service.ts` uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global daemon. CodeNomad owns no private port, database, registration, or daemon PID and never stops the daemon on backend shutdown. +- WSL requires Windows localhost forwarding and runs the Linux CLI lifecycle inside the distribution; never inspect or signal Linux PIDs from Windows. +- OpenCode owns the global daemon's standard state and database. Configured allowed environment variables apply only when CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. +- Explicit **Stop Workspace** evicts the native location/resources. Closing a tab or window only detaches that local UI and must never delete or evict the workspace. - OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`. - The proxy is method/path allowlisted, so new upstream functionality is not exposed automatically. - Shell mode (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from background shells and interactive PTYs. - Location-scoped background shells use `client.shell.*` and are listed in the Status panel. The UI refreshes them on Shell events and reconnect, displays native metadata, and supports ownership-checked removal. `client.pty.*` remains reserved for interactive terminals. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. - Native events are volatile. Reconnect handlers must refetch authoritative state instead of assuming missed events will replay. - Git mutations and Yolo policy remain CodeNomad-owned server boundaries. +- Native desktop identity is channel plus config profile: one singleton process/backend per profile, multiple UUID windows, focus on second launch by default, and `--new-window` for another window. Stable, dev, and non-default profiles isolate native/browser/client state; OpenCode sessions/messages stay shared while tabs, drafts, and views are per-window. +- Desktop restore uses a V3 per-window envelope over the V2 content-addressed partition graph. Preserve atomic publication/migration, ownership write fencing, and post-commit conservative garbage collection in both Electron and Tauri. +- Native SideCar/browser previews are sandboxed without same-origin access, so DOM comment inspection is web-only. ### Key UI Files diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 791c21e7..230b9625 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -19,6 +19,9 @@ The migration removes the V1 compatibility layer rather than maintaining both in - Route events from owned Git worktrees to their corresponding logical CodeNomad workspace. - Query native sessions by validated project scope, then traverse every descendant depth with native cursors. - Resolve locationless session events through native session ownership so prompt status and output reach the correct logical workspace. +- Run one native process and one CodeNomad backend per channel/config profile. The native singleton focuses the most-recent window on a second launch unless `--new-window` is supplied; each process may host multiple UUID-backed windows. +- Keep OpenCode sessions/messages in the shared global service while tabs, drafts, view state, and restore membership remain local to each window. +- Store desktop restore state in a V3 per-window envelope whose snapshots use the V2 content-addressed partition graph. Native hosts prepare immutable partitions before atomically publishing the root, fence writes and migrations on current ownership, and collect only unreferenced partitions after publication. ## Removed Legacy Components @@ -44,25 +47,31 @@ The migration removes the V1 compatibility layer rather than maintaining both in - Prevent OpenCode `Set-Cookie` headers from being relayed to the browser. - Avoid logging unredacted secret-bearing proxy request bodies. - Expose only an explicit method/path allowlist through the OpenCode proxy. New upstream APIs require an intentional proxy and ownership review; future OpenCode functionality is not automatic. -- Share a consistent service registration location between Windows and WSL. +- Connect to one externally owned global daemon using OpenCode's standard service registration, state, and database in the selected host or WSL environment; CodeNomad owns no private port, database, registration, or daemon PID. - Keep server and UI on the same reviewed client release. The selected `opencode2` CLI is updated independently and validated through service health and API compatibility rather than an exact version gate. -- Wrap native `Service.ensure`/`Service.stop` with CodeNomad's ownership checks. A lifecycle lease records the registration, authenticated endpoint, daemon PID plus process-start identity and host/WSL namespace, and a hash of the launch command/environment. Proof can transfer between live CodeNomad processes through peer leases; the final host process calls `Service.stop` only after proving there are no live peers and every recorded identity still matches. WSL uses the authenticated native health stop endpoint and never allows the client's Windows `process.kill` fallback to target a Linux PID. -- Queue location eviction when its final logical owner is removed, then perform it only during proven final shared-service shutdown so another CodeNomad process cannot lose active upstream state. -- Force the V2 service database to `~/.local/share/opencode2/opencode.db`. V1 and V2 must never point at the same database because their schemas are incompatible. +- Use the selected host or WSL CLI's official `service status`, `service start`, and `service get password` commands, then require bounded authenticated loopback health validation. +- WSL support requires Windows localhost forwarding and uses the Linux CLI lifecycle inside the selected distribution; CodeNomad performs no cross-namespace PID operations. +- Explicit **Stop Workspace** evicts that location and its resources through the global service without stopping the daemon. Ordinary tab/window close only detaches local UI state and never evicts a location. +- Never stop the global service during CodeNomad backend shutdown; clear only in-memory connection state. +- Keep service registration, state, and database paths at OpenCode's platform defaults. +- A missing global daemon receives configured `server.environmentVariables` and the current `NODE_EXTRA_CA_CERTS` on `service start` only. An already-running daemon is unaffected; legacy `OPENCODE_DB` and `XDG_STATE_HOME` settings are ignored rather than taking storage ownership. - Isolate V2 restore state under `~/.codenomad/client-state/v2` and copy V1 state non-destructively on first launch, preserving downgrade history. +- Sandbox native SideCar/browser previews without `allow-same-origin`; DOM comment inspection remains available only in the web client. ## Current Status - Server and UI are pinned to `0.0.0-beta-17595`; the selected `opencode2` CLI is not exact-version-gated. - The updater advertises and installs only that pinned startup-compatible version; both repository lockfiles resolve the same client, protocol, and schema release. -- Shared-service shutdown delegates host daemons to native `Service.stop` after CodeNomad proves ownership and excludes live peer leases; WSL uses native authenticated health stop. +- Shared-service shutdown clears only local connection state and never stops host or WSL daemons. +- Stable, dev, and non-default config profiles have isolated native singleton, backend, browser storage, and client-state scopes. Each scope supports multiple UUID windows; OpenCode sessions/messages remain global while tabs, drafts, and views are per-window. +- Client-state V3 stores one record per window over the V2 content-addressed partition graph with atomic root publication, ownership fencing, migration guards, and post-commit conservative garbage collection. - The UI uses native Forms instead of the removed Question API and `@opencode-ai/client/solid` `createData` for live message, tool, permission, and form reduction. Live projections merge into REST-loaded history instead of replacing it. - Session inventory uses native project/subpath/parent/order cursor pagination across all descendant depths; later pages receive native active status, and internal OpenCode stream generations trigger authoritative UI reconciliation even when browser SSE remains connected. - The proxy validates the decoded scope of native session cursors before forwarding them and supports native global Form reply/cancel routes without treating `global` as a session ID. - Before deleting a worktree, the server inventories the complete native project, evacuates affected session families with verification and rollback, and fails closed for direct API callers. One canonical folder maps to one logical workspace instead of creating non-isolated duplicates. -- The current working tree retains the isolated V2 database, deferred location eviction, and proxy path/location ownership validation. +- The current working tree retains proxy path/location ownership validation while leaving global service storage and lifecycle ownership to OpenCode. - Current installed client declarations provide background Shell list/get/create/output/timeout/remove and lifecycle events. Shell output pagination keeps the native cursor authoritative. Interactive PTY APIs remain separate and are not used by the background-process panel. -- Local validation passes server/UI/Electron typechecks, the CI UI partitions, Electron native tests, server tests (with three platform skips), standalone server lockfile dry-run installation, UI/server/Electron builds, Tauri `cargo check --locked`, and `git diff --check`. +- This documentation pass ran stale-architecture greps and `git diff --check`; it did not run or claim the pending build matrix or real-service smoke test. ## Remaining Work @@ -82,7 +91,7 @@ The final validation should include: ### Required Parallel UI Smoke -CodeNomad V1 is the working environment and must remain open and untouched. V2 always uses `~/.local/share/opencode2/opencode.db`. Build into `codenomad-v2-slots/build-{A|B}/release`, copy the validated output into the corresponding `codenomad-v2-slots/{A|B}` deployment slot, and record its source, hash, slot, and deployment time in `deployment.json`. +CodeNomad V1 is the working environment and must remain open and untouched. The OpenCode global daemon keeps its standard state and database; CodeNomad does not provide a private V2 database override. Build into `codenomad-v2-slots/build-{A|B}/release`, copy the validated output into the corresponding `codenomad-v2-slots/{A|B}` deployment slot, and record its source, hash, slot, and deployment time in `deployment.json`. For a first V2 launch, start the deployed slot beside V1 from PowerShell with a dedicated CDP port, WebView profile, Rust backtraces, and Node source maps: diff --git a/README.md b/README.md index c22b85c0..f1fb239a 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ npx @neuralnomads/codenomad-dev --password --launch SideCars let you open local web tools inside CodeNomad as tabs. +Native desktop previews are sandboxed without same-origin access. DOM comment inspection is available only in the web client. +
Configuration diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index e4568c53..a3da1e23 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -94,10 +94,11 @@ dev-docs/ Development documentation ### 2. Shared Service Management -- CodeNomad server discovers or launches one service through its hardened lifecycle; proven host shutdown delegates to native `Service.stop`, while WSL uses native authenticated health stop +- CodeNomad uses the official host or WSL CLI lifecycle to connect to one externally owned global service - Workspace folders become validated native locations - UI traffic stays behind the CodeNomad proxy -- Shutdown transfers proof to a live peer or stops only the exact proven daemon when no peer remains +- Shutdown clears only CodeNomad's in-memory connection state and never stops the daemon +- Explicit Stop Workspace evicts its location; tab/window close only detaches local UI ### 3. One Shared Service, Location-Scoped Clients @@ -165,9 +166,11 @@ dev-docs/ Development documentation ## Current OpenCode Baseline - Experimental protocol client: server and UI use the same reviewed version; the runtime `opencode2` CLI is independently updated and checked through service/API compatibility, not an exact version gate -- Service: one shared endpoint managed by CodeNomad's lease-locked process-proof lifecycle +- Service: one externally owned global endpoint with a CodeNomad-pinned host or WSL identity - Workspaces: native locations/directories -- Database: V2 always uses `~/.local/share/opencode2/opencode.db`, separate from V1 +- Database: OpenCode's global default; configured startup environment applies only when CodeNomad starts a missing daemon +- Desktop: one singleton process/backend per channel/config profile, multiple UUID windows, and isolated stable/dev/non-default native state +- Restore: V3 per-window envelope over a V2 content-addressed partition graph; OpenCode sessions/messages are shared while tabs/drafts/views are local - Events: volatile native stream with authoritative reconnect reconciliation - Proxy: explicit method/path allowlist; upstream additions are not automatic - Shell mode and instructions: native session APIs, separate from background Shell and PTY management diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 85ce0dc6..63b1c180 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -15,19 +15,26 @@ There is no `@opencode-ai/sdk` integration and no `packages/opencode-plugin` pac ## Shared Service And Locations -`packages/server/src/workspaces/opencode-service.ts` uses native discovery and headers while retaining a custom launcher that serializes lifecycle changes with cross-process leases, records the registration and authenticated endpoint, proves daemon and CodeNomad PIDs with process-start identity in the host or WSL namespace, and binds that proof to a launch command/environment hash. Live peer leases can inherit that proof; only the final verified CodeNomad process may call `Service.stop`. WSL daemons use the same authenticated graceful-stop request instead because the published fallback signals PIDs in the caller's namespace. +`packages/server/src/workspaces/opencode-service.ts` runs the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle, validates the authenticated loopback endpoint, and pins that identity while active. It connects to one externally owned global daemon and never stops it on backend shutdown. CodeNomad owns no private daemon port, database, registration, or PID. -The V2 service always uses `~/.local/share/opencode2/opencode.db`. V1 and V2 must use separate databases because their schemas are incompatible. +OpenCode owns the daemon's standard state and database. Configured allowed environment variables and `NODE_EXTRA_CA_CERTS` apply only if CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. WSL support requires Windows localhost forwarding and executes the Linux CLI lifecycle inside the selected distribution without cross-namespace PID operations. `packages/server/src/workspaces/manager.ts` treats selected folders as native OpenCode locations: 1. Validate the directory with `client.location.get`. 2. Store the returned `LocationRef` and publish the logical workspace. 3. Reuse the shared service for every additional directory. -4. Queue eviction after the final logical owner is deleted. -5. Flush queued evictions only during proven final shared-service shutdown, then stop only the exact daemon covered by transferable CodeNomad process proof. +4. On explicit **Stop Workspace**, evict the location and its resources from the global service, then remove CodeNomad's logical workspace. -Workspaces are not OpenCode processes and do not own ports or PIDs. +Workspaces are not OpenCode processes and do not own ports or PIDs. Closing an ordinary tab or native window only detaches local UI state and never evicts the location. + +## Native Profiles, Windows, And Client State + +Electron and Tauri run one native singleton process and one CodeNomad backend per channel/config profile. A second launch focuses the most-recent window by default; `--new-window` creates another UUID-backed window. Stable, dev, and non-default config profiles isolate singleton identity, backend/browser storage, and client state. + +OpenCode sessions and messages remain shared through the global daemon. Window membership, tabs, drafts, view state, and native bounds are local to each UUID window. Client-state V3 is a per-window envelope over the V2 content-addressed partition graph: immutable partitions are prepared before atomic root publication, writes and migrations are fenced by current ownership, and garbage collection runs after publication while retaining every partition referenced by any window. + +Native SideCar/browser previews use a sandbox without `allow-same-origin`, so they cannot inspect the embedded DOM. DOM comment inspection is available only in the web client. ## API Boundaries diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 68d35f8b..d360e7a6 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -8,11 +8,9 @@ Do not add `@opencode-ai/sdk`, old `{ data, error }` SDK wrappers, `createOpenco ## Server Integration -`OpenCodeSharedService` is the sole service adapter. Production uses `Service.discover` and `Service.headers`, then a custom launcher with lease and process-identity proof. Proven host shutdown delegates to native `Service.stop`; WSL uses native authenticated health stop to avoid the client's Windows PID fallback. +`OpenCodeSharedService` is the sole service adapter. Host and WSL paths both execute the selected CLI's official `service status`, `service start`, and `service get password` commands, validate the authenticated loopback endpoint, pin its identity while active, and never stop the externally owned global daemon on backend shutdown. -Startup and shutdown are serialized by filesystem leases. Each CodeNomad process proves its own PID/start identity and launch signature; service proof contains the registration contents, endpoint credentials, daemon PID/start identity, and host/WSL namespace. On exit, an owner transfers that proof to an elected live peer and releases its lease; a replacement can also inherit matching proof from a stale peer under the lifecycle lock. The final process stops only after all peers are proven stale/absent and the registration, endpoint, process identity, and launch signature still match; uncertainty retains the lease and leaks safely rather than signaling a PID. - -The V2 service database is fixed at `~/.local/share/opencode2/opencode.db`; V1 and V2 schemas must never share a database. The complete environment is part of the launch signature and takes effect on service start/restart, not on an already-running daemon. +OpenCode owns the daemon's standard state, database, and registration; CodeNomad has no private port, database, registration, or daemon PID. Allowed configured environment variables and the current `NODE_EXTRA_CA_CERTS` are passed only to `service start` for a missing daemon. Existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` are ignored. WSL requires Windows localhost forwarding and runs this lifecycle inside Linux without Windows PID operations. Workspace creation passes a native location: @@ -20,7 +18,13 @@ Workspace creation passes a native location: await client.location.get({ location: { directory } }) ``` -`WorkspaceManager` records the returned directory/workspace ID. After the final logical owner is removed, eviction is queued and is sent only during proven final shared-service shutdown, after cross-process peer and daemon identity checks. +`WorkspaceManager` records the returned directory/workspace ID. Explicit **Stop Workspace** evicts that location/resources and removes the logical workspace without stopping the global daemon. Ordinary tab/window close only detaches local UI state and does not call the delete/eviction path. + +## Native Windows And Restore State + +Each channel/config profile has one native singleton process and one backend. A second launch focuses the MRU window unless `--new-window` requests another UUID window; stable, dev, and non-default config profiles use isolated native/browser/client-state scopes. + +OpenCode sessions/messages are shared service data. Each window separately persists tab membership, drafts, view state, and native bounds in the client-state V3 envelope. Snapshot V2 is a SHA-256 content-addressed partition graph. Electron and Tauri prepare immutable partitions, fence migration/root replacement on current ownership and renderer authority, atomically publish the envelope, then conservatively sweep partitions no window references. ## UI Integration @@ -47,6 +51,8 @@ Native background Shells are location-scoped and listed in the Status panel. `pa Never trust a browser-supplied worktree path. Resolve workspace/worktree ownership server-side. +Native SideCar/browser preview iframes are sandboxed without `allow-same-origin`; DOM comment inspection is therefore web-only. + ## CodeNomad-Owned Mutations Git status/diff and mutations remain CodeNomad APIs. Stage, unstage and commit execute validated Git commands in `packages/server/src/workspaces/git-mutations.ts`; the UI calls `/api/workspaces/:id/worktrees/:slug/git-*`. diff --git a/packages/electron-app/README.md b/packages/electron-app/README.md index 6e9a6809..30ca08e3 100644 --- a/packages/electron-app/README.md +++ b/packages/electron-app/README.md @@ -9,6 +9,10 @@ The Electron app wraps the CodeNomad UI and Server into a standalone executable. - Global keyboard shortcuts - Application menu integration +Each channel/config profile runs one native singleton process and one backend. A second launch focuses the most-recent window unless `--new-window` requests another UUID-backed window; stable, dev, and non-default config profiles keep native/browser/client state isolated. OpenCode sessions/messages remain shared, while tabs, drafts, and views belong to each window. + +Restore state uses a V3 per-window envelope over the V2 content-addressed partition graph, with atomic publication/migration, ownership-fenced writes, and post-commit conservative garbage collection. + ## Development To run the Electron app in development mode: diff --git a/packages/electron-app/electron/main/client-state-envelope.ts b/packages/electron-app/electron/main/client-state-envelope.ts new file mode 100644 index 00000000..20ec6f6e --- /dev/null +++ b/packages/electron-app/electron/main/client-state-envelope.ts @@ -0,0 +1,208 @@ +import { createHash, randomUUID } from "node:crypto" +import { isDeepStrictEqual } from "node:util" +import { + CLIENT_STATE_PARTITION_ENVELOPE_VERSION, + CLIENT_STATE_PARTITION_PROTOCOL_VERSION, + MAX_CLIENT_STATE_ROOT_BYTES, + validateClientStatePartitionRoot, + validatePartitionKeys, +} from "./client-state-partitions" +import { normalizeNativeWindowState, type NativeWindowState } from "./window-state" + +export const CLIENT_STATE_MONOLITHIC_VERSION = 1 +export const CLIENT_STATE_ENVELOPE_VERSION = 3 +export const MAX_CLIENT_SNAPSHOT_BYTES = 1024 * 1024 +export const MAX_CLIENT_STATE_WINDOWS = 16 + +export interface ClientWindowStateRecord { + restoreEnabled: boolean + snapshot?: unknown + window?: NativeWindowState + partitionProtocolVersion?: typeof CLIENT_STATE_PARTITION_PROTOCOL_VERSION + partitionKeys?: string[] +} + +export interface PersistedClientState { + version: typeof CLIENT_STATE_ENVELOPE_VERSION + activeWindowId: string + windowOrder: string[] + windows: Record +} + +export interface ParsedClientState { + state: PersistedClientState + unsupportedFutureEnvelope: boolean +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ +const hasOwn = (value: object, key: string) => Object.prototype.hasOwnProperty.call(value, key) +const hasOnlyKeys = (value: Record, keys: readonly string[]) => + Object.keys(value).every((key) => keys.includes(key)) + +export function isWindowId(value: unknown): value is string { + return typeof value === "string" && UUID.test(value) +} + +export function createClientState(windowId: string = randomUUID()): PersistedClientState { + if (!isWindowId(windowId)) throw new TypeError("Invalid client state window ID") + return { + version: CLIENT_STATE_ENVELOPE_VERSION, + activeWindowId: windowId, + windowOrder: [windowId], + windows: { [windowId]: { restoreEnabled: true } }, + } +} + +export function deterministicLegacyWindowId(content: string | Buffer): string { + const bytes = createHash("sha256").update(content).digest().subarray(0, 16) + bytes[6] = (bytes[6]! & 0x0f) | 0x50 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + const hex = bytes.toString("hex") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +function strictWindowState(value: unknown): NativeWindowState | undefined { + const normalized = normalizeNativeWindowState(value) + return normalized && isDeepStrictEqual(value, normalized) ? normalized : undefined +} + +function snapshotSize(value: unknown): number | undefined { + const serialized = JSON.stringify(value) + return serialized === undefined ? undefined : Buffer.byteLength(serialized, "utf8") +} + +function parseRecord(value: unknown): ClientWindowStateRecord | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + const record = value as Record + if (!hasOnlyKeys(record, ["restoreEnabled", "snapshot", "window", "partitionProtocolVersion", "partitionKeys"]) + || typeof record.restoreEnabled !== "boolean") return undefined + + const window = hasOwn(record, "window") ? strictWindowState(record.window) : undefined + if (hasOwn(record, "window") && !window) return undefined + const hasProtocol = hasOwn(record, "partitionProtocolVersion") + const hasPartitionKeys = hasOwn(record, "partitionKeys") + const size = hasOwn(record, "snapshot") ? snapshotSize(record.snapshot) : undefined + if (hasOwn(record, "snapshot") && size === undefined) return undefined + + let partitionKeys: string[] | undefined + if (hasProtocol || hasPartitionKeys) { + partitionKeys = validatePartitionKeys(record.partitionKeys) + const rootKeys = validateClientStatePartitionRoot(record.snapshot) + if (!hasProtocol || !hasPartitionKeys + || record.partitionProtocolVersion !== CLIENT_STATE_PARTITION_PROTOCOL_VERSION + || !hasOwn(record, "snapshot") || !partitionKeys || !rootKeys + || size! > MAX_CLIENT_STATE_ROOT_BYTES + || rootKeys.length !== partitionKeys.length + || rootKeys.some((key, index) => key !== partitionKeys![index])) return undefined + } else if (size !== undefined && size > MAX_CLIENT_SNAPSHOT_BYTES) { + return undefined + } + + return { + restoreEnabled: record.restoreEnabled, + ...(hasOwn(record, "snapshot") ? { snapshot: record.snapshot } : {}), + ...(window ? { window } : {}), + ...(partitionKeys ? { + partitionProtocolVersion: CLIENT_STATE_PARTITION_PROTOCOL_VERSION, + partitionKeys, + } : {}), + } +} + +function parseV3(envelope: Record): PersistedClientState | undefined { + if (!hasOnlyKeys(envelope, ["version", "activeWindowId", "windowOrder", "windows"]) + || !isWindowId(envelope.activeWindowId) + || !Array.isArray(envelope.windowOrder) + || envelope.windowOrder.length > MAX_CLIENT_STATE_WINDOWS + || !envelope.windows || typeof envelope.windows !== "object" || Array.isArray(envelope.windows)) return undefined + + const windowOrder = envelope.windowOrder + if (windowOrder.some((id) => !isWindowId(id)) || new Set(windowOrder).size !== windowOrder.length + || (windowOrder.length > 0 && !windowOrder.includes(envelope.activeWindowId))) return undefined + const source = envelope.windows as Record + if (Object.keys(source).length !== windowOrder.length || Object.keys(source).some((id) => !windowOrder.includes(id))) return undefined + const windows: Record = {} + for (const id of windowOrder) { + if (!hasOwn(source, id)) return undefined + const record = parseRecord(source[id]) + if (!record) return undefined + windows[id] = record + } + return { + version: CLIENT_STATE_ENVELOPE_VERSION, + activeWindowId: envelope.activeWindowId, + windowOrder: [...windowOrder], + windows, + } +} + +function parseLegacy(envelope: Record, windowId: string): PersistedClientState | undefined { + const version = envelope.version + if (version === CLIENT_STATE_PARTITION_ENVELOPE_VERSION) { + if (!hasOnlyKeys(envelope, ["version", "restoreEnabled", "snapshot", "window", "protocolVersion", "partitionKeys"]) + || typeof envelope.restoreEnabled !== "boolean" + || envelope.protocolVersion !== CLIENT_STATE_PARTITION_PROTOCOL_VERSION + || !hasOwn(envelope, "snapshot")) return undefined + const partitionKeys = validatePartitionKeys(envelope.partitionKeys) + const rootKeys = validateClientStatePartitionRoot(envelope.snapshot) + const window = hasOwn(envelope, "window") ? strictWindowState(envelope.window) : undefined + const size = snapshotSize(envelope.snapshot) + if (!partitionKeys || !rootKeys || size === undefined || size > MAX_CLIENT_STATE_ROOT_BYTES + || rootKeys.length !== partitionKeys.length + || rootKeys.some((key, index) => key !== partitionKeys[index]) + || (hasOwn(envelope, "window") && !window)) return undefined + return { + version: CLIENT_STATE_ENVELOPE_VERSION, + activeWindowId: windowId, + windowOrder: [windowId], + windows: { [windowId]: { + restoreEnabled: envelope.restoreEnabled, + snapshot: envelope.snapshot, + ...(window ? { window } : {}), + partitionProtocolVersion: CLIENT_STATE_PARTITION_PROTOCOL_VERSION, + partitionKeys, + } }, + } + } + + if (version !== CLIENT_STATE_MONOLITHIC_VERSION + || !hasOnlyKeys(envelope, ["version", "restoreEnabled", "snapshot", "window"]) + || (hasOwn(envelope, "restoreEnabled") && typeof envelope.restoreEnabled !== "boolean")) return undefined + const window = hasOwn(envelope, "window") ? strictWindowState(envelope.window) : undefined + const size = hasOwn(envelope, "snapshot") ? snapshotSize(envelope.snapshot) : undefined + if ((hasOwn(envelope, "snapshot") && (size === undefined || size > MAX_CLIENT_SNAPSHOT_BYTES)) + || (hasOwn(envelope, "window") && !window)) return undefined + return { + version: CLIENT_STATE_ENVELOPE_VERSION, + activeWindowId: windowId, + windowOrder: [windowId], + windows: { [windowId]: { + restoreEnabled: typeof envelope.restoreEnabled === "boolean" ? envelope.restoreEnabled : true, + ...(hasOwn(envelope, "snapshot") ? { snapshot: envelope.snapshot } : {}), + ...(window ? { window } : {}), + } }, + } +} + +export function parseClientState(value: string, legacyWindowId?: string): ParsedClientState { + try { + const candidate = JSON.parse(value) as unknown + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new TypeError("Invalid client state envelope") + const envelope = candidate as Record + const state = envelope.version === CLIENT_STATE_ENVELOPE_VERSION + ? parseV3(envelope) + : parseLegacy(envelope, legacyWindowId ?? deterministicLegacyWindowId(value)) + if (!state) throw new TypeError("Invalid client state envelope") + return { state, unsupportedFutureEnvelope: false } + } catch (error) { + console.warn("[client-state] unsupported state file", error) + const fallbackWindowId = legacyWindowId ?? randomUUID() + const state = createClientState(fallbackWindowId) + state.windows[fallbackWindowId]!.restoreEnabled = false + return { state, unsupportedFutureEnvelope: true } + } +} + +export function retainedPartitionKeys(state: PersistedClientState): string[] { + return [...new Set(state.windowOrder.flatMap((id) => state.windows[id]!.partitionKeys ?? []))].sort() +} diff --git a/packages/electron-app/electron/main/client-state-ipc.test.ts b/packages/electron-app/electron/main/client-state-ipc.test.ts index 15f1698e..50f72e41 100644 --- a/packages/electron-app/electron/main/client-state-ipc.test.ts +++ b/packages/electron-app/electron/main/client-state-ipc.test.ts @@ -3,7 +3,7 @@ import test from "node:test" import type { IpcMainInvokeEvent } from "electron" import { setupClientStateIPC } from "./client-state-ipc" -function harness() { +function harness(persisted = true) { const handlers = new Map unknown>() const listeners = new Map void>() const frame = { url: "http://127.0.0.1:3000/app" } @@ -15,35 +15,48 @@ function harness() { const window = { isDestroyed: () => false, webContents } let current: typeof window | null = window const calls: string[] = [] + const windowId = "11111111-1111-4111-8111-111111111111" const state = { - claimClientStateAccess: (token: unknown) => { calls.push(`claim:${token}`); return true }, - assertRendererAccessToken: (token: unknown) => calls.push(`assert:${token}`), - loadClientState: () => ({ isPrimary: true }), + activeWindowId: windowId, + claimClientStateAccess: (token: unknown, id: string) => { calls.push(`claim:${token}:${id}`); return true }, + assertRendererAccessToken: (token: unknown, id: string) => calls.push(`assert:${token}:${id}`), + loadClientState: (id: string) => { calls.push(`load:${id}`); return { isPrimary: true } }, saveClientState: () => true, + commitClientStatePartitions: () => true, + loadClientStatePartition: () => null, setRestoreEnabled: () => true, clearClientState: () => true, - resetRendererAccessToken: () => calls.push("reset"), + resetRendererAccessToken: (id: string) => calls.push(`reset:${id}`), } const bind = setupClientStateIPC( { handle: (channel, listener) => handlers.set(channel, listener) }, state as never, - () => current as never, + (sender) => current && sender === (webContents as never) ? { id: windowId, persisted, window: current as never } : undefined, () => ["http://127.0.0.1:3000"], ) bind(window as never) - return { calls, frame, handlers, listeners, setCurrent: (value: typeof window | null) => { current = value }, webContents, window } + return { calls, frame, handlers, listeners, setCurrent: (value: typeof window | null) => { current = value }, webContents, window, windowId } } +test("ephemeral local renderers receive secondary state without claiming a missing V3 record", async () => { + const h = harness(false) + const event = { sender: h.webContents, senderFrame: h.frame } + assert.equal(await h.handlers.get("client-state:claimAccess")!(event as never, "token"), false) + assert.deepEqual(h.calls, []) + await assert.rejects(h.handlers.get("client-state:load")!(event as never, "token") as Promise, /unavailable/) +}) + test("IPC channels enforce the current main sender, frame, origin, and token", async () => { const h = harness() assert.deepEqual([...h.handlers.keys()], [ "client-state:claimAccess", "client-state:load", "client-state:save", + "client-state:commitPartitions", "client-state:loadPartition", "client-state:setRestoreEnabled", "client-state:clear", ]) const event = { sender: h.webContents, senderFrame: h.frame } await h.handlers.get("client-state:claimAccess")!(event as never, "token") await h.handlers.get("client-state:load")!(event as never, "token") - assert.deepEqual(h.calls, ["claim:token", "assert:token"]) + assert.deepEqual(h.calls, [`claim:token:${h.windowId}`, `assert:token:${h.windowId}`, `load:${h.windowId}`]) for (const invalid of [ { sender: {}, senderFrame: h.frame }, @@ -52,13 +65,46 @@ test("IPC channels enforce the current main sender, frame, origin, and token", a ]) await assert.rejects(h.handlers.get("client-state:load")!(invalid as never, "token") as Promise) }) -test("only the registered current window can reset renderer authority", () => { +test("two local renderers claim and use independent window tokens", async () => { + const handlers = new Map() + const calls: string[] = [] + const makeWindow = (id: string, contentsId: number) => { + const frame = { url: "http://127.0.0.1:3000/app" } + const webContents = { id: contentsId, mainFrame: frame, getURL: () => frame.url, on: () => {} } + return { id, window: { isDestroyed: () => false, webContents }, frame, webContents } + } + const first = makeWindow("11111111-1111-4111-8111-111111111111", 1) + const second = makeWindow("22222222-2222-4222-8222-222222222222", 2) + const records = [first, second] + setupClientStateIPC({ handle: (channel, handler) => { + assert.equal(handlers.has(channel), false, `duplicate handler ${channel}`) + handlers.set(channel, handler) + } }, { + claimClientStateAccess: (token: unknown, id: string) => { calls.push(`claim:${id}:${token}`); return true }, + assertRendererAccessToken: (token: unknown, id: string) => calls.push(`assert:${id}:${token}`), + loadClientState: (id: string) => { calls.push(`load:${id}`); return {} }, + } as never, (sender) => records.find((record) => record.webContents === (sender as never)) as never, () => ["http://127.0.0.1:3000"]) + for (const [record, token] of [[first, "one"], [second, "two"]] as const) { + const event = { sender: record.webContents, senderFrame: record.frame } + await handlers.get("client-state:claimAccess")!(event, token) + await handlers.get("client-state:load")!(event, token) + } + assert.deepEqual(calls, [ + `claim:${first.id}:one`, `assert:${first.id}:one`, `load:${first.id}`, + `claim:${second.id}:two`, `assert:${second.id}:two`, `load:${second.id}`, + ]) + assert.equal(handlers.size, 7) +}) + +test("only a registered local window can reset its renderer authority", () => { const h = harness() h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/next") h.listeners.get("render-process-gone")!() - assert.deepEqual(h.calls, ["reset", "reset"]) + assert.equal(h.calls.length, 2) + assert.match(h.calls[0]!, /^reset:[0-9a-f-]{36}$/) + assert.equal(h.calls[1], h.calls[0]) h.setCurrent(null) h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/late") h.listeners.get("destroyed")!() - assert.deepEqual(h.calls, ["reset", "reset"]) + assert.equal(h.calls.length, 2) }) diff --git a/packages/electron-app/electron/main/client-state-ipc.ts b/packages/electron-app/electron/main/client-state-ipc.ts index 51b0b0c5..78e1029b 100644 --- a/packages/electron-app/electron/main/client-state-ipc.ts +++ b/packages/electron-app/electron/main/client-state-ipc.ts @@ -30,47 +30,54 @@ function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | n export function setupClientStateIPC( ipcMain: IPCRegistrar, clientState: ClientStateManager, - getMainWindow: () => BrowserWindow | null, + resolveWindow: (sender: IpcMainInvokeEvent["sender"]) => { id: string; window: BrowserWindow; persisted?: boolean } | undefined, getAllowedOrigins: (window: BrowserWindow | null) => string[], ) { const validate = (event: IpcMainInvokeEvent) => { - const window = getMainWindow() + const record = resolveWindow(event.sender) + const window = record?.window ?? null validateSender(event, window, getAllowedOrigins(window)) + return record! } const handle = ( channel: string, - operation: (argument: unknown, token: unknown) => unknown, + operation: (argument: unknown, token: unknown, windowId: string) => unknown, ) => ipcMain.handle(channel, async (event, token: unknown, argument: unknown) => { - validate(event) - clientState.assertRendererAccessToken(token) - return operation(argument, token) + const { id: windowId, persisted } = validate(event) + if (persisted === false) throw new Error("Client state persistence is unavailable for this window") + clientState.assertRendererAccessToken(token, windowId) + return operation(argument, token, windowId) }) ipcMain.handle("client-state:claimAccess", async (event, token: unknown) => { - validate(event) - return clientState.claimClientStateAccess(token) + const record = validate(event) + return record.persisted === false ? false : clientState.claimClientStateAccess(token, record.id) }) - handle("client-state:load", () => clientState.loadClientState()) - handle("client-state:save", (snapshot, token) => clientState.saveClientState(snapshot, token)) - handle("client-state:setRestoreEnabled", (enabled, token) => { + handle("client-state:load", (_argument, _token, windowId) => clientState.loadClientState(windowId)) + handle("client-state:save", (snapshot, token, windowId) => clientState.saveClientState(snapshot, token, windowId)) + handle("client-state:commitPartitions", (payload, token, windowId) => clientState.commitClientStatePartitions(payload, token, windowId)) + handle("client-state:loadPartition", (key, token, windowId) => clientState.loadClientStatePartition(key, token, windowId)) + handle("client-state:setRestoreEnabled", (enabled, token, windowId) => { if (typeof enabled !== "boolean") throw new Error("Restore enabled must be a boolean") - return clientState.setRestoreEnabled(enabled, token) + return clientState.setRestoreEnabled(enabled, token, windowId) }) - handle("client-state:clear", (_argument, token) => clientState.clearClientState(token)) + handle("client-state:clear", (_argument, token, windowId) => clientState.clearClientState(token, windowId)) return (window: BrowserWindow): void => { window.webContents.on("did-navigate", (_event, url) => { - if (getMainWindow() === window && shouldResetRendererAccessTokenForNavigation( + const record = resolveWindow(window.webContents) + if (record && record.persisted !== false && shouldResetRendererAccessTokenForNavigation( url, false, true, (target) => isAllowedRendererOrigin(target, getAllowedOrigins(window)), )) { - clientState.resetRendererAccessToken() + clientState.resetRendererAccessToken(record.id) } }) const resetDestroyedRenderer = () => { - if (getMainWindow() === window) clientState.resetRendererAccessToken() + const record = resolveWindow(window.webContents) + if (record && record.persisted !== false) clientState.resetRendererAccessToken(record.id) } window.webContents.on("render-process-gone", resetDestroyedRenderer) window.webContents.on("destroyed", resetDestroyedRenderer) diff --git a/packages/electron-app/electron/main/client-state-partitions.ts b/packages/electron-app/electron/main/client-state-partitions.ts new file mode 100644 index 00000000..14d03a8a --- /dev/null +++ b/packages/electron-app/electron/main/client-state-partitions.ts @@ -0,0 +1,221 @@ +import { createHash, randomUUID } from "node:crypto" +import { link, lstat, mkdir, open, readFile, readdir, rm, unlink } from "node:fs/promises" +import { join } from "node:path" +import { hasErrorCode } from "./client-state-process" + +export const CLIENT_STATE_PARTITION_PROTOCOL_VERSION = 1 +export const CLIENT_STATE_PARTITION_ENVELOPE_VERSION = 2 +export const MAX_CLIENT_STATE_ROOT_BYTES = 1024 * 1024 +const MAX_PARTITION_BYTES = 1024 * 1024 +const MAX_COMMIT_BYTES = 8 * 1024 * 1024 +const MAX_PARTITION_KEYS = 4096 +const PARTITION_KEY = /^[0-9a-f]{64}$/ +const PARTITION_DIRECTORY = "partitions" + +export interface ClientStatePartitionCommit { + protocolVersion: 1 + snapshot: unknown + partitions: Record + partitionKeys: string[] +} + +export interface ValidatedClientStatePartitionCommit { + snapshot: unknown + partitions: Array + partitionKeys: string[] +} + +const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex") + +export function isPartitionKey(value: unknown): value is string { + return typeof value === "string" && PARTITION_KEY.test(value) +} + +export function validatePartitionKeys(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.length > MAX_PARTITION_KEYS) return undefined + const keys: string[] = [] + for (const key of value) { + if (!isPartitionKey(key) || (keys.length > 0 && keys[keys.length - 1]! >= key)) return undefined + keys.push(key) + } + return keys +} + +export function validateClientStatePartitionRoot(value: unknown): string[] | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + const root = value as Record + if (root.version !== CLIENT_STATE_PARTITION_ENVELOPE_VERSION || !isPartitionKey(root.sessionPartition)) return undefined + const partitionKeys = validatePartitionKeys(root.partitionKeys) + return partitionKeys?.includes(root.sessionPartition) ? partitionKeys : undefined +} + +export function validateClientStatePartitionCommit(value: unknown): ValidatedClientStatePartitionCommit { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Invalid client state partition commit") + } + const candidate = value as Partial + if (candidate.protocolVersion !== CLIENT_STATE_PARTITION_PROTOCOL_VERSION) { + throw new TypeError("Unsupported client state partition protocol") + } + if (!candidate.partitions || typeof candidate.partitions !== "object" || Array.isArray(candidate.partitions)) { + throw new TypeError("Invalid client state partitions") + } + const partitionKeys = validatePartitionKeys(candidate.partitionKeys) + if (!partitionKeys) throw new TypeError("Invalid client state partition keys") + const partitions = Object.entries(candidate.partitions).sort(([left], [right]) => left.localeCompare(right)) + if (partitions.length > MAX_PARTITION_KEYS) throw new RangeError("Too many client state partitions") + + const serializedSnapshot = JSON.stringify(candidate.snapshot) + if (serializedSnapshot === undefined) throw new TypeError("Client state root must be JSON-serializable") + const normalizedSnapshot = JSON.parse(serializedSnapshot) as unknown + const rootPartitionKeys = validateClientStatePartitionRoot(normalizedSnapshot) + if (!rootPartitionKeys || rootPartitionKeys.some((key, index) => key !== partitionKeys[index]) + || rootPartitionKeys.length !== partitionKeys.length) { + throw new TypeError("Client state root partition keys do not match the commit") + } + if (partitions.length !== rootPartitionKeys.length + || partitions.some(([key], index) => key !== rootPartitionKeys[index])) { + throw new TypeError("Client state partitions do not match the root") + } + const rootBytes = Buffer.byteLength(serializedSnapshot, "utf8") + if (rootBytes > MAX_CLIENT_STATE_ROOT_BYTES) throw new RangeError("Client state root exceeds the 1 MiB limit") + let commitBytes = rootBytes + for (const [key, content] of partitions) { + if (!isPartitionKey(key) || typeof content !== "string") { + throw new TypeError("Invalid client state partition reference") + } + const size = Buffer.byteLength(content, "utf8") + if (size > MAX_PARTITION_BYTES) throw new RangeError("Client state partition exceeds the 1 MiB limit") + commitBytes += size + if (commitBytes > MAX_COMMIT_BYTES) throw new RangeError("Client state partition commit exceeds the 8 MiB limit") + if (digest(content) !== key) throw new TypeError("Client state partition digest mismatch") + } + return { snapshot: normalizedSnapshot, partitions, partitionKeys: rootPartitionKeys } +} + +export async function syncDirectory(path: string): Promise { + let directory + try { + directory = await open(path, "r") + await directory.sync() + } catch (error) { + // Windows' stdlib cannot open directories for fsync; do not mask other I/O failures. + if (process.platform === "win32" && (hasErrorCode(error, "EISDIR") || hasErrorCode(error, "EPERM") || hasErrorCode(error, "EINVAL"))) return + throw error + } finally { + await directory?.close() + } +} + +export class ClientStatePartitionStore { + private readonly directory: string + + constructor(root: string) { + this.directory = join(root, PARTITION_DIRECTORY) + } + + async prepare(commit: ValidatedClientStatePartitionCommit, authorityValid: () => void): Promise { + await mkdir(this.directory, { recursive: true }) + await this.assertDirectory() + let published = false + for (const [key, content] of commit.partitions) published = await this.writeImmutable(key, content, authorityValid) || published + if (published) { + await this.assertDirectory() + await syncDirectory(this.directory) + authorityValid() + } + for (const key of commit.partitionKeys) { + const content = await this.readVerified(key) + authorityValid() + if (content === null) throw new Error(`Missing client state partition ${key}`) + } + } + + async load(key: string, authorityValid: () => void): Promise { + if (!await this.assertDirectory(true)) return null + const content = await this.readVerified(key) + authorityValid() + return content + } + + async sweep(partitionKeys: readonly string[], authorityValid: () => void): Promise { + const retained = new Set(partitionKeys) + if (!await this.assertDirectory(true)) return + let entries: string[] + try { + entries = await readdir(this.directory) + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return + throw error + } + authorityValid() + for (const entry of entries) { + if (!isPartitionKey(entry) || retained.has(entry)) continue + const path = join(this.directory, entry) + const stats = await lstat(path) + if (!stats.isFile() || stats.isSymbolicLink()) continue + await unlink(path) + authorityValid() + } + } + + private async assertDirectory(allowMissing = false): Promise { + try { + const stats = await lstat(this.directory) + if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error("Invalid client state partition directory") + return true + } catch (error) { + if (allowMissing && hasErrorCode(error, "ENOENT")) return false + throw error + } + } + + private async readVerified(key: string): Promise { + try { + const path = join(this.directory, key) + const stats = await lstat(path) + if (!stats.isFile() || stats.isSymbolicLink()) return null + const bytes = await readFile(path) + if (bytes.length > MAX_PARTITION_BYTES || digest(bytes) !== key) return null + return bytes.toString("utf8") + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return null + throw error + } + } + + private async writeImmutable(key: string, content: string, authorityValid: () => void): Promise { + const path = join(this.directory, key) + const existing = await this.readVerified(key) + authorityValid() + if (existing !== null) return false + try { + await lstat(path) + throw new Error(`Invalid existing client state partition ${key}`) + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error + } + + const temporary = join(this.directory, `.${key}.${process.pid}.${randomUUID()}.tmp`) + try { + const file = await open(temporary, "wx", 0o600) + try { + await file.writeFile(content, "utf8") + await file.sync() + } finally { + await file.close() + } + authorityValid() + try { + await link(temporary, path) + } catch (error) { + if (!hasErrorCode(error, "EEXIST") || await this.readVerified(key) === null) throw error + return false + } + authorityValid() + return true + } finally { + await rm(temporary, { force: true }).catch(() => {}) + } + } +} diff --git a/packages/electron-app/electron/main/client-state.test.ts b/packages/electron-app/electron/main/client-state.test.ts index 5254f71f..4f662494 100644 --- a/packages/electron-app/electron/main/client-state.test.ts +++ b/packages/electron-app/electron/main/client-state.test.ts @@ -1,15 +1,40 @@ import assert from "node:assert/strict" -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { createHash } from "node:crypto" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs" import { writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" import { ClientStateManager, type ClientStateWriter } from "./client-state" +import { deterministicLegacyWindowId, parseClientState } from "./client-state-envelope" -function harness(t: test.TestContext, initial?: object) { +test("legacy migration UUIDs are deterministic from exact envelope bytes", () => { + const vectors = [ + ["{\"version\":1}", "2430f1a2-ad29-52d0-8678-85488a4c89e2"], + ["{ \"version\": 1, \"restoreEnabled\": false }", "e6a1425a-ebb1-502f-b79c-d92772fa763b"], + ] as const + for (const [content, expected] of vectors) { + assert.equal(deterministicLegacyWindowId(content), expected) + assert.equal(parseClientState(content).state.activeWindowId, expected) + } +}) + +test("V3 permits a temporarily empty local-window set and activates the next new record", async (t) => { + const h = harness(t) + const manager = h.create() + const closed = manager.activeWindowId + assert.equal(await manager.removeWindow(closed), true) + assert.deepEqual(manager.windowIds, []) + const next = "99999999-9999-4999-8999-999999999999" + assert.equal(await manager.addWindow(next), next) + assert.equal(manager.activeWindowId, next) + assert.deepEqual(manager.windowIds, [next]) +}) + +function harness(t: test.TestContext, initial?: unknown) { const directory = mkdtempSync(join(tmpdir(), "codenomad-state-")) const statePath = join(directory, "client-state.json") - if (initial) writeFileSync(statePath, JSON.stringify(initial)) + if (initial !== undefined) writeFileSync(statePath, JSON.stringify(initial)) let failing = false let writes = 0 const managers: ClientStateManager[] = [] @@ -29,6 +54,11 @@ function harness(t: test.TestContext, initial?: object) { return { create, directory, statePath, fail: (value: boolean) => { failing = value }, writes: () => writes } } +function persistedRecord(path: string, windowId?: string): Record { + const envelope = JSON.parse(readFileSync(path, "utf8")) + return envelope.windows[windowId ?? envelope.activeWindowId] +} + test("renderer access is exclusive per document and resettable", async (t) => { const manager = harness(t, { version: 1, restoreEnabled: true }).create() assert.throws(() => manager.claimClientStateAccess(""), /nonempty string/) @@ -71,7 +101,7 @@ test("cross-host ownership is required in addition to each host-local election", crossHostDependencies, processOwner: { pid: 8102, runToken: "electron", processStartIdentity: "electron-start" }, }) - assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null }) + assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) await secondary.drainAndReleasePrimary() await primary.drainAndReleasePrimary() @@ -113,7 +143,7 @@ test("legacy migration prefers disabled and ignores malformed candidates", async writeFileSync(join(electron, "client-state.json"), "malformed") writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: false, snapshot: { savedAt: 1 } })) const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri }) - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) assert.equal(JSON.parse(readFileSync(join(root, "shared", "client-state.json"), "utf8")).restoreEnabled, false) await manager.drainAndReleasePrimary() }) @@ -136,7 +166,7 @@ test("V1 shared state is copied once and V2 mutations remain isolated", async (t const legacyShared = join(shared, "client-state.json"), v2State = join(v2, "client-state.json") mkdirSync(electron, { recursive: true }); mkdirSync(shared, { recursive: true }) t.after(() => rmSync(root, { recursive: true, force: true })) - const legacyBytes = '{\n "version": 1, "restoreEnabled": true, "snapshot": { "source": "v1" }, "v1Only": true\n}' + const legacyBytes = '{\n "version": 1, "restoreEnabled": true, "snapshot": { "source": "v1" }\n}' writeFileSync(legacyShared, legacyBytes) writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { source: "host-local" } })) @@ -147,10 +177,10 @@ test("V1 shared state is copied once and V2 mutations remain isolated", async (t assert.equal(readFileSync(v2State, "utf8"), legacyBytes) assert.deepEqual(manager.loadClientState().snapshot, { source: "v1" }) await manager.saveClientState({ source: "v2-save" }) - assert.equal(JSON.parse(readFileSync(v2State, "utf8")).snapshot.source, "v2-save") + assert.equal(persistedRecord(v2State).snapshot.source, "v2-save") assert.equal(readFileSync(legacyShared, "utf8"), legacyBytes) assert.equal(await manager.setRestoreEnabled(false), true) - assert.equal(JSON.parse(readFileSync(v2State, "utf8")).restoreEnabled, false) + assert.equal(persistedRecord(v2State).restoreEnabled, false) assert.equal(readFileSync(legacyShared, "utf8"), legacyBytes) assert.equal(await manager.clearClientState(), true) assert.equal(readFileSync(legacyShared, "utf8"), legacyBytes) @@ -167,17 +197,34 @@ test("V1 shared state is copied once and V2 mutations remain isolated", async (t await restarted.drainAndReleasePrimary() }) +test("unshipped partitioned V2 shared state is not copied over shipped V1 migration", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), shared = join(root, "shared"), v2 = join(shared, "v2") + mkdirSync(electron, { recursive: true }); mkdirSync(shared, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(shared, "client-state.json"), JSON.stringify({ version: 2, restoreEnabled: true })) + writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { source: "shipped-v1" } })) + + const manager = new ClientStateManager(electron, undefined, { + crossHostElectionDirectory: join(v2, "election"), + legacySharedStatePath: join(shared, "client-state.json"), + }) + assert.deepEqual(manager.loadClientState().snapshot, { source: "shipped-v1" }) + assert.equal(JSON.parse(readFileSync(join(v2, "client-state.json"), "utf8")).version, 1) + await manager.drainAndReleasePrimary() +}) + test("ownership loss immediately disables restore reads and mutations", async (t) => { const h = harness(t, { version: 1, restoreEnabled: true, snapshot: { tabs: ["must-stop"] }, - window: { width: 900, height: 700 }, + window: { bounds: { x: 0, y: 0, width: 900, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 }, }) const manager = h.create() writeFileSync(join(h.directory, "election", "primary.owner.json", "owner.json"), "malformed") assert.equal(manager.isPrimary, false) - assert.deepEqual(manager.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null }) + assert.deepEqual(manager.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) assert.equal(manager.getWindowState(), undefined) assert.equal(await manager.saveClientState({ ignored: true }), false) }) @@ -188,8 +235,8 @@ test("failed preference and clear writes roll memory and suppression back", asyn await manager.saveClientState({ kept: true }) h.fail(true) await assert.rejects(manager.setRestoreEnabled(false), /injected write failure/) - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: { kept: true } }) - assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).restoreEnabled, true) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: { kept: true }, partitionProtocolVersion: 1 }) + assert.equal(persistedRecord(h.statePath).restoreEnabled, true) await assert.rejects(manager.clearClientState(), /injected write failure/) h.fail(false) await manager.setRestoreEnabled(true) @@ -220,10 +267,10 @@ test("disabling restore atomically removes snapshot/window and survives restart" const before = h.writes() assert.equal(await manager.setRestoreEnabled(false), true) assert.equal(h.writes(), before + 1) - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) assert.equal(manager.getWindowState(), undefined) - const disabled = JSON.stringify({ version: 1, restoreEnabled: false }) - assert.equal(readFileSync(h.statePath, "utf8"), disabled) + const disabled = readFileSync(h.statePath, "utf8") + assert.equal(persistedRecord(h.statePath).restoreEnabled, false) await manager.drainAndReleasePrimary() const restarted = h.create() assert.equal(await restarted.saveWindowState({ bounds: { x: 0, y: 0, width: 800, height: 600 }, maximized: false, fullscreen: false, zoomFactor: 1 }), true) @@ -242,13 +289,28 @@ test("drain freezes mutations and waits for admitted writes", async (t) => { let settled = false const drain = manager.drainAndReleasePrimary().finally(() => { settled = true }) await assert.rejects(manager.saveClientState({ late: true }), /frozen for shutdown/) + await assert.rejects(manager.addWindow(), /frozen for shutdown/) await new Promise((resolve) => setImmediate(resolve)) assert.equal(settled, false) assert.equal(manager.isPrimary, true) + assert.equal(existsSync(join(h.directory, "client-state.primary.lock")), true) + assert.equal(existsSync(join(h.directory, "election", "primary.owner.json", "owner.json")), true) release() await Promise.all([admitted, drain]) assert.equal(manager.isPrimary, false) - assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { admitted: true }) + assert.equal(existsSync(join(h.directory, "client-state.primary.lock")), false) + assert.equal(existsSync(join(h.directory, "election", "primary.owner.json")), false) + assert.deepEqual(persistedRecord(h.statePath).snapshot, { admitted: true }) +}) + +test("a non-primary manager does not claim that a new V3 window was persisted", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const primary = h.create() + const secondary = h.create() + assert.equal(secondary.isPrimary, false) + assert.equal(await secondary.addWindow("11111111-1111-4111-8111-111111111111"), null) + assert.equal(secondary.windowIds.length, 1) + await Promise.all([secondary.drainAndReleasePrimary(), primary.drainAndReleasePrimary()]) }) test("an old writer cannot replace a successor after PID reuse", async (t) => { @@ -270,14 +332,14 @@ test("an old writer cannot replace a successor after PID reuse", async (t) => { release() await assert.rejects(staleWrite, /ownership changed before atomic replacement/) await assert.rejects(oldDrain, /ownership changed before atomic replacement/) - assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { successor: true }) + assert.deepEqual(persistedRecord(h.statePath).snapshot, { successor: true }) }) test("future envelopes are preserved until a successful explicit clear", async (t) => { const future = { version: 7, restoreEnabled: false, snapshot: { future: true }, futurePreference: "keep" } const h = harness(t, future) const manager = h.create(undefined, { pid: process.pid, runToken: "future-before-restart", processStartIdentity: "old-start" }) - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) assert.equal(await manager.saveClientState({ ignored: true }), true) assert.equal(await manager.setRestoreEnabled(false), false) assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) @@ -285,9 +347,10 @@ test("future envelopes are preserved until a successful explicit clear", async ( const restarted = h.create() assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) assert.equal(await restarted.clearClientState(), true) - assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), { version: 1, restoreEnabled: false }) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).version, 3) + assert.equal(persistedRecord(h.statePath).restoreEnabled, false) assert.equal(await restarted.saveClientState({ supported: true }), true) - assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { supported: true }) + assert.deepEqual(persistedRecord(h.statePath).snapshot, { supported: true }) }) test("failed future-envelope clear leaves persistence blocked", async (t) => { @@ -301,3 +364,351 @@ test("failed future-envelope clear leaves persistence blocked", async (t) => { assert.equal(writes, 1) assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) }) + +const partitionKey = (content: string) => createHash("sha256").update(content).digest("hex") +const partitionRoot = (partitionKeys: string[], fields: Record = {}) => ({ + ...fields, + version: 2, + sessionPartition: partitionKeys[0], + partitionKeys, +}) + +test("envelope numeric fields follow JavaScript JSON number semantics", () => { + const key = "a".repeat(64) + const windowId = "11111111-1111-4111-8111-111111111111" + const envelopes = (version: string) => version.startsWith("1") + ? `{"version":${version},"restoreEnabled":true}` + : version.startsWith("2") + ? `{"version":${version},"restoreEnabled":true,"snapshot":{"version":2.0,"sessionPartition":"${key}","partitionKeys":["${key}"]},"protocolVersion":1.0,"partitionKeys":["${key}"]}` + : `{"version":${version},"activeWindowId":"${windowId}","windowOrder":["${windowId}"],"windows":{"${windowId}":{"restoreEnabled":true}}}` + for (const version of ["1", "1.0", "2", "2.0", "3", "3.0"]) { + assert.equal(parseClientState(envelopes(version), windowId).unsupportedFutureEnvelope, false, version) + } + for (const version of ["1.5", "2.5", "3.5"]) { + assert.equal(parseClientState(envelopes(version), windowId).unsupportedFutureEnvelope, true, version) + } + assert.equal(parseClientState(envelopes("2").replace("1.0", "1.5"), windowId).unsupportedFutureEnvelope, true) + assert.equal(parseClientState(envelopes("2").replace("2.0", "2.5"), windowId).unsupportedFutureEnvelope, true) +}) + +test("V1 and V2 migrate in memory without load rewrites and mutate as V3", async (t) => { + const content = "legacy partition", key = partitionKey(content) + for (const initial of [ + { version: 1, restoreEnabled: true, snapshot: { legacy: 1 } }, + { version: 2, restoreEnabled: true, snapshot: partitionRoot([key]), protocolVersion: 1, partitionKeys: [key] }, + ]) { + const h = harness(t, initial) + const before = readFileSync(h.statePath, "utf8") + const manager = h.create() + assert.equal(readFileSync(h.statePath, "utf8"), before) + assert.match(manager.activeWindowId, /^[0-9a-f-]{36}$/) + await manager.saveWindowState({ bounds: { x: 1, y: 2, width: 900, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 }) + const persisted = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(persisted.version, 3) + assert.equal(persisted.activeWindowId, manager.activeWindowId) + } +}) + +test("V3 window records isolate state, tokens, partitions, clear, and removal", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + const windowA = manager.activeWindowId + const windowB = "11111111-1111-4111-8111-111111111111" + await manager.addWindow(windowB) + + manager.claimClientStateAccess("token-a", windowA) + manager.claimClientStateAccess("token-b", windowB) + assert.throws(() => manager.assertRendererAccessToken("token-a", windowB), /has not been claimed/) + await manager.saveClientState({ window: "a" }, "token-a", windowA) + await manager.saveClientState({ window: "b" }, "token-b", windowB) + await manager.saveWindowState({ bounds: { x: 1, y: 2, width: 900, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 }, windowA) + await manager.saveWindowState({ bounds: { x: 3, y: 4, width: 1000, height: 800 }, maximized: true, fullscreen: false, zoomFactor: 1.25 }, windowB) + assert.deepEqual(manager.loadClientState(windowA).snapshot, { window: "a" }) + assert.deepEqual(manager.loadClientState(windowB).snapshot, { window: "b" }) + assert.notDeepEqual(manager.getWindowState(windowA), manager.getWindowState(windowB)) + + const contentA = "partition a", keyA = partitionKey(contentA) + const contentB = "partition b", keyB = partitionKey(contentB) + await manager.commitClientStatePartitions({ protocolVersion: 1, snapshot: partitionRoot([keyA]), partitions: { [keyA]: contentA }, partitionKeys: [keyA] }, "token-a", windowA) + await manager.commitClientStatePartitions({ protocolVersion: 1, snapshot: partitionRoot([keyB]), partitions: { [keyB]: contentB }, partitionKeys: [keyB] }, "token-b", windowB) + assert.equal(await manager.loadClientStatePartition(keyB, "token-a", windowA), null) + assert.equal(await manager.loadClientStatePartition(keyB, "token-b", windowB), contentB) + await manager.clearClientState("token-a", windowA) + assert.equal(existsSync(join(h.directory, "partitions", keyA)), false) + assert.equal(existsSync(join(h.directory, "partitions", keyB)), true) + assert.equal(await manager.loadClientStatePartition(keyB, "token-b", windowB), contentB) + + assert.equal(await manager.removeWindow(windowA), true) + const envelope = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(envelope.activeWindowId, windowB) + assert.deepEqual(envelope.windowOrder, [windowB]) + assert.deepEqual(Object.keys(envelope.windows), [windowB]) + assert.equal(existsSync(join(h.directory, "partitions", keyB)), true) + assert.equal(await manager.removeWindow(windowB), true) + assert.equal(existsSync(join(h.directory, "partitions", keyB)), false) +}) + +test("concurrent window list mutations preserve the limit and a valid active cursor", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + const additions = Array.from({ length: 16 }, (_, index) => manager.addWindow(`00000000-0000-4000-8000-${index.toString().padStart(12, "0")}`)) + const results = await Promise.allSettled(additions) + assert.equal(results.filter(({ status }) => status === "fulfilled").length, 15) + assert.equal(results.filter(({ status }) => status === "rejected").length, 1) + let envelope = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(envelope.windowOrder.length, 16) + assert.equal(Object.keys(envelope.windows).length, 16) + + const keep = envelope.windowOrder.slice(0, 2) + await Promise.all(envelope.windowOrder.slice(2).map((id: string) => manager.removeWindow(id))) + const removals = await Promise.all([manager.removeWindow(keep[0]), manager.removeWindow(keep[1])]) + assert.deepEqual(removals, [true, true]) + envelope = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(envelope.windowOrder.length, 0) + assert.deepEqual(Object.keys(envelope.windows), envelope.windowOrder) + assert.match(envelope.activeWindowId, /^[0-9a-f-]{36}$/) +}) + +test("delayed focus operations serialize no-op decisions with earlier focus writes", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + let block = false + let started!: () => void + let release!: () => void + const began = new Promise((resolve) => { started = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + const manager = h.create(async (path, value) => { + await writeFile(path, value) + if (block) { block = false; started(); await gate } + }) + const original = manager.activeWindowId + const other = "11111111-1111-4111-8111-111111111111" + await manager.addWindow(other) + + block = true + const delayed = manager.saveClientState({ blocked: true }) + await began + const focusOther = manager.setActiveWindow(other) + const focusOriginal = manager.setActiveWindow(original) + release() + await Promise.all([delayed, focusOther, focusOriginal]) + + assert.equal(manager.activeWindowId, original) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).activeWindowId, original) +}) + +test("queued focus rejects a window removed by an earlier queued mutation", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + let block = false + let started!: () => void + let release!: () => void + const began = new Promise((resolve) => { started = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + const manager = h.create(async (path, value) => { + await writeFile(path, value) + if (block) { block = false; started(); await gate } + }) + const removed = "22222222-2222-4222-8222-222222222222" + await manager.addWindow(removed) + + block = true + const delayed = manager.saveClientState({ blocked: true }) + await began + const removal = manager.removeWindow(removed) + const focus = manager.setActiveWindow(removed) + release() + await Promise.all([delayed, removal]) + await assert.rejects(focus, /Unknown client state window/) + + const envelope = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(envelope.windows[removed], undefined) + assert.equal(envelope.windowOrder.includes(envelope.activeWindowId), true) +}) + +test("invalid V3 remains byte-frozen until explicit clear", async (t) => { + const windowId = "11111111-1111-4111-8111-111111111111" + const invalid = ` { "version": 3, "activeWindowId": "${windowId}", "windowOrder": ["${windowId}"], "windows": { "${windowId}": { "restoreEnabled": true, "unknown": 1 } } } ` + const h = harness(t) + writeFileSync(h.statePath, invalid) + const manager = h.create() + assert.equal(await manager.saveClientState({ ignored: true }), true) + assert.equal(readFileSync(h.statePath, "utf8"), invalid) + assert.equal(await manager.clearClientState(), true) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).version, 3) +}) + +test("partition commits validate protocol and hashes", async (t) => { + const manager = harness(t, { version: 1, restoreEnabled: true }).create() + const content = "partition" + const key = partitionKey(content) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 2, snapshot: partitionRoot([key]), partitions: { [key]: content }, partitionKeys: [key], + }), /Unsupported.*protocol/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: { [key]: "wrong" }, partitionKeys: [key], + }), /digest mismatch/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: { [key]: content }, partitionKeys: [key, key], + }), /partition keys/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: {}, partitionKeys: [key], + }), /do not match the root/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: { [key]: content, [partitionKey("extra")]: "extra" }, partitionKeys: [key], + }), /do not match the root/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: { [key]: content }, partitionKeys: [partitionKey("other")], + }), /do not match the commit/) + + const partitions: Record = {} + const partitionKeys: string[] = [] + for (let index = 0; index < 8; index++) { + const value = `${index}${"x".repeat(1024 * 1024 - 1)}` + const valueKey = partitionKey(value) + partitions[valueKey] = value + partitionKeys.push(valueKey) + } + partitionKeys.sort() + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot(partitionKeys), partitions, partitionKeys, + }), /exceeds the 8 MiB limit/) +}) + +test("partition commit/read preserves the old root on failure and clear sweeps", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + const oldContent = "old partition", oldKey = partitionKey(oldContent) + const nextContent = "next partition", nextKey = partitionKey(nextContent) + await manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([oldKey], { root: "old" }), partitions: { [oldKey]: oldContent }, partitionKeys: [oldKey], + }) + assert.equal(await manager.loadClientStatePartition(oldKey), oldContent) + assert.deepEqual(persistedRecord(h.statePath), { + restoreEnabled: true, snapshot: partitionRoot([oldKey], { root: "old" }), partitionProtocolVersion: 1, partitionKeys: [oldKey], + }) + + h.fail(true) + await assert.rejects(manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([nextKey], { root: "next" }), partitions: { [nextKey]: nextContent }, partitionKeys: [nextKey], + }), /injected write failure/) + assert.deepEqual(manager.loadClientState().snapshot, partitionRoot([oldKey], { root: "old" })) + assert.equal(await manager.loadClientStatePartition(oldKey), oldContent) + assert.equal(existsSync(join(h.directory, "partitions", nextKey)), true) + + h.fail(false) + await manager.clearClientState() + assert.equal(existsSync(join(h.directory, "partitions", oldKey)), false) + assert.equal(existsSync(join(h.directory, "partitions", nextKey)), false) +}) + +test("supported v2 window writes persist metadata and monolithic saves collect partitions", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create(undefined, { pid: process.pid, runToken: "partition-window", processStartIdentity: "before-restart" }) + const content = "window partition", key = partitionKey(content) + await manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key], { root: true }), partitions: { [key]: content }, partitionKeys: [key], + }) + const window = { bounds: { x: 10, y: 20, width: 1000, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1.25 } + await manager.saveWindowState(window) + await manager.flush() + const partitioned = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(partitioned.version, 3) + assert.equal(persistedRecord(h.statePath).partitionProtocolVersion, 1) + assert.deepEqual(persistedRecord(h.statePath).partitionKeys, [key]) + await manager.drainAndReleasePrimary() + + const restarted = h.create() + assert.deepEqual(restarted.getWindowState(), window) + assert.equal(await restarted.loadClientStatePartition(key), content) + await restarted.saveClientState({ monolithic: true }) + assert.deepEqual(persistedRecord(h.statePath), { + restoreEnabled: true, snapshot: { monolithic: true }, window, + }) + assert.equal(existsSync(join(h.directory, "partitions", key)), false) +}) + +test("malformed and future v2 roots fence writes and GC until explicit clear", async (t) => { + const content = "keep orphan", key = partitionKey(content) + for (const initial of [ + { version: 2, restoreEnabled: true, snapshot: {}, protocolVersion: 2, partitionKeys: [key] }, + { version: 2, restoreEnabled: true, snapshot: {}, protocolVersion: 1, partitionKeys: [key, key] }, + { version: 2, restoreEnabled: true, snapshot: {}, protocolVersion: 1, partitionKeys: [key.toUpperCase()] }, + { version: 2, restoreEnabled: true, snapshot: "x".repeat(1024 * 1024), protocolVersion: 1, partitionKeys: [key] }, + { version: 3, restoreEnabled: true, snapshot: {}, protocolVersion: 1, partitionKeys: [key] }, + { version: 2, restoreEnabled: true, snapshot: partitionRoot([key]), protocolVersion: 1, partitionKeys: [key], extra: true }, + { version: 2, restoreEnabled: true, snapshot: partitionRoot([key]), protocolVersion: 1, partitionKeys: [partitionKey("other")] }, + { version: 1, restoreEnabled: true, snapshot: {}, extra: true }, + { version: 1.5, restoreEnabled: true }, + { version: "2", restoreEnabled: true }, + "not an envelope", + ]) { + const h = harness(t, initial) + const directory = join(h.directory, "partitions") + mkdirSync(directory) + writeFileSync(join(directory, key), content) + const manager = h.create() + const before = h.writes() + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) + assert.equal(await manager.loadClientStatePartition(key), null) + assert.equal(await manager.saveClientState({ ignored: true }), true) + assert.equal(await manager.saveWindowState({ bounds: { x: 0, y: 0, width: 800, height: 600 }, maximized: false, fullscreen: false, zoomFactor: 1 }), true) + await manager.flush() + assert.equal(h.writes(), before) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), initial) + assert.equal(readFileSync(join(directory, key), "utf8"), content) + assert.equal(await manager.clearClientState(), true) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).version, 3) + assert.equal(persistedRecord(h.statePath).restoreEnabled, false) + assert.equal(existsSync(join(directory, key)), false) + } + + const malformed = harness(t) + writeFileSync(malformed.statePath, "{not json") + const manager = malformed.create() + assert.equal(await manager.saveClientState({ ignored: true }), true) + await manager.flush() + assert.equal(readFileSync(malformed.statePath, "utf8"), "{not json") +}) + +test("v1 roots with partition metadata are fenced until clear", async (t) => { + const content = "not referenced by v1", key = partitionKey(content) + const h = harness(t, { + version: 1, restoreEnabled: true, snapshot: { monolithic: true }, protocolVersion: 1, partitionKeys: [key], + }) + const directory = join(h.directory, "partitions") + mkdirSync(directory) + writeFileSync(join(directory, key), content) + const manager = h.create() + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) + assert.equal(await manager.loadClientStatePartition(key), null) + const before = readFileSync(h.statePath, "utf8") + await manager.saveWindowState({ bounds: { x: 0, y: 0, width: 800, height: 600 }, maximized: false, fullscreen: false, zoomFactor: 1 }) + assert.equal(readFileSync(h.statePath, "utf8"), before) +}) + +test("partition directory and GC reject unsafe filesystem entries", async (t) => { + const occupied = harness(t, { version: 1, restoreEnabled: true }) + writeFileSync(join(occupied.directory, "partitions"), "not a directory") + await assert.rejects(occupied.create().commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([partitionKey("occupied")]), partitions: { [partitionKey("occupied")]: "occupied" }, partitionKeys: [partitionKey("occupied")], + }), /partition directory|EEXIST/) + + const linked = harness(t, { version: 1, restoreEnabled: true }) + const target = join(linked.directory, "partition-target") + mkdirSync(target) + symlinkSync(target, join(linked.directory, "partitions"), process.platform === "win32" ? "junction" : "dir") + await assert.rejects(linked.create().commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([partitionKey("linked")]), partitions: { [partitionKey("linked")]: "linked" }, partitionKeys: [partitionKey("linked")], + }), /Invalid client state partition directory/) + + const gc = harness(t, { version: 1, restoreEnabled: true }) + const directory = join(gc.directory, "partitions") + const removable = partitionKey("orphan") + mkdirSync(directory) + writeFileSync(join(directory, removable), "orphan") + writeFileSync(join(directory, "unrelated.txt"), "keep") + mkdirSync(join(directory, "f".repeat(64))) + await gc.create().clearClientState() + assert.equal(existsSync(join(directory, removable)), false) + assert.equal(existsSync(join(directory, "unrelated.txt")), true) + assert.equal(existsSync(join(directory, "f".repeat(64))), true) +}) diff --git a/packages/electron-app/electron/main/client-state.ts b/packages/electron-app/electron/main/client-state.ts index 1ca8cd46..fa812a9e 100644 --- a/packages/electron-app/electron/main/client-state.ts +++ b/packages/electron-app/electron/main/client-state.ts @@ -22,41 +22,47 @@ import { resolveLegacyTauriDataDirectory, type CrossHostLeaseDependencies, } from "./client-state-cross-host" -import { normalizeNativeWindowState } from "./window-state" +import { normalizeNativeWindowState, type NativeWindowState } from "./window-state" +import { + CLIENT_STATE_PARTITION_PROTOCOL_VERSION, + ClientStatePartitionStore, + isPartitionKey, + validateClientStatePartitionCommit, + syncDirectory, +} from "./client-state-partitions" +import { + CLIENT_STATE_ENVELOPE_VERSION, + CLIENT_STATE_MONOLITHIC_VERSION, + MAX_CLIENT_SNAPSHOT_BYTES, + MAX_CLIENT_STATE_WINDOWS, + createClientState, + isWindowId, + parseClientState, + retainedPartitionKeys, + type ClientWindowStateRecord, + type ParsedClientState, + type PersistedClientState, +} from "./client-state-envelope" -const CLIENT_STATE_VERSION = 1 const CLIENT_STATE_FILENAME = "client-state.json" const PRIMARY_LOCK_FILENAME = "client-state.primary.lock" const REGISTRATION_LOCK_FILENAME = "client-state.registration.lock" const CROSS_HOST_PARTICIPANT_GRACE_MS = 50 -export const MAX_CLIENT_SNAPSHOT_BYTES = 1024 * 1024 - -export interface WindowBounds { - x: number - y: number - width: number - height: number -} - -export interface NativeWindowState { - bounds: WindowBounds - maximized: boolean - fullscreen: boolean - zoomFactor: number -} +export { MAX_CLIENT_SNAPSHOT_BYTES } from "./client-state-envelope" +export type { NativeWindowState, WindowBounds } from "./window-state" export interface ClientStateLoadResult { isPrimary: boolean restoreEnabled: boolean snapshot: unknown | null + partitionProtocolVersion: typeof CLIENT_STATE_PARTITION_PROTOCOL_VERSION } -interface PersistedClientState { - version: typeof CLIENT_STATE_VERSION +interface LegacyPersistedClientState { + version: typeof CLIENT_STATE_MONOLITHIC_VERSION restoreEnabled: boolean snapshot?: unknown - window?: NativeWindowState } export type ClientStateWriter = ( @@ -82,46 +88,18 @@ async function writeClientStateTemporary(temporaryPath: string, serializedState: } } -interface ParsedClientState { - state: PersistedClientState - unsupportedFutureEnvelope: boolean -} - -function parseClientState(value: string): ParsedClientState { - const defaults: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true } - try { - const candidate = JSON.parse(value) as Record - if (candidate && typeof candidate.version === "number" && candidate.version > CLIENT_STATE_VERSION) { - return { state: { ...defaults, restoreEnabled: false }, unsupportedFutureEnvelope: true } - } - if (!candidate || candidate.version !== CLIENT_STATE_VERSION) { - return { state: defaults, unsupportedFutureEnvelope: false } - } - - const state: PersistedClientState = { - version: CLIENT_STATE_VERSION, - restoreEnabled: typeof candidate.restoreEnabled === "boolean" ? candidate.restoreEnabled : true, - } - if (Object.prototype.hasOwnProperty.call(candidate, "snapshot")) { - state.snapshot = candidate.snapshot - } - const windowState = normalizeNativeWindowState(candidate.window) - if (windowState) { - state.window = windowState - } - return { state, unsupportedFutureEnvelope: false } - } catch (error) { - console.warn("[client-state] ignored invalid state file", error) - return { state: defaults, unsupportedFutureEnvelope: false } - } -} - -function legacyCandidate(path: string, host: "electron" | "tauri"): { host: string; state: PersistedClientState; savedAt: number; hasSnapshot: boolean } | undefined { +function legacyCandidate(path: string, host: "electron" | "tauri"): { host: string; state: LegacyPersistedClientState; savedAt: number; hasSnapshot: boolean } | undefined { try { const candidate = JSON.parse(readFileSync(path, "utf8")) as Record - if (!candidate || candidate.version !== CLIENT_STATE_VERSION) return undefined - const parsed = parseClientState(JSON.stringify(candidate)).state - delete parsed.window + if (!candidate || candidate.version !== CLIENT_STATE_MONOLITHIC_VERSION) return undefined + const parsedEnvelope = parseClientState(JSON.stringify(candidate)) + if (parsedEnvelope.unsupportedFutureEnvelope) return undefined + const record = parsedEnvelope.state.windows[parsedEnvelope.state.activeWindowId]! + const parsed: LegacyPersistedClientState = { + version: CLIENT_STATE_MONOLITHIC_VERSION, + restoreEnabled: record.restoreEnabled, + ...(record.snapshot === undefined ? {} : { snapshot: record.snapshot }), + } const snapshot = candidate.snapshot as Record | undefined const savedAt = typeof snapshot?.savedAt === "number" && Number.isFinite(snapshot.savedAt) ? snapshot.savedAt : -1 return { host, state: parsed, savedAt, hasSnapshot: snapshot !== undefined } @@ -133,7 +111,7 @@ function legacyCandidate(path: string, host: "electron" | "tauri"): { host: stri function isFutureLegacyCandidate(path: string): boolean { try { const candidate = JSON.parse(readFileSync(path, "utf8")) as Record - return typeof candidate?.version === "number" && candidate.version > CLIENT_STATE_VERSION + return typeof candidate?.version === "number" && candidate.version > CLIENT_STATE_MONOLITHIC_VERSION } catch { return false } @@ -145,15 +123,16 @@ export class ClientStateManager { private readonly lockPath: string private readonly legacyTauriDataPath: string | null private readonly owner: ProcessOwner - private state: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true } + private readonly partitions: ClientStatePartitionStore + private state: PersistedClientState = createClientState() private writeQueue: Promise = Promise.resolve() private drainAndReleasePromise: Promise | undefined private crossHostRegistration: CrossHostRegistration | undefined private primary = false - private persistenceSuppressed = false + private persistenceSuppressed = new Set() private unsupportedFutureEnvelope = false private frozen = false - private rendererAccessToken: string | undefined + private rendererAccessTokens = new Map() constructor( userDataPath: string, @@ -172,6 +151,7 @@ export class ClientStateManager { ? join(dirname(crossHostElectionDirectory), CLIENT_STATE_FILENAME) : resolveCrossHostStatePath() mkdirSync(dirname(this.statePath), { recursive: true }) + this.partitions = new ClientStatePartitionStore(dirname(this.statePath)) this.lockPath = join(userDataPath, PRIMARY_LOCK_FILENAME) const registrationLockPath = join(userDataPath, REGISTRATION_LOCK_FILENAME) @@ -232,9 +212,13 @@ export class ClientStateManager { const futureLegacyBlocked = this.unsupportedFutureEnvelope const persisted = this.readState() this.state = futureLegacyBlocked - ? { version: CLIENT_STATE_VERSION, restoreEnabled: false } + ? (() => { + const state = createClientState() + state.windows[state.activeWindowId]!.restoreEnabled = false + return state + })() : persisted.state - this.persistenceSuppressed = !this.state.restoreEnabled + this.persistenceSuppressed = new Set(this.state.windowOrder.filter((id) => !this.state.windows[id]!.restoreEnabled)) this.unsupportedFutureEnvelope = futureLegacyBlocked || persisted.unsupportedFutureEnvelope } } @@ -250,46 +234,87 @@ export class ClientStateManager { } } - loadClientState(): ClientStateLoadResult { + get activeWindowId(): string { + return this.state.activeWindowId + } + + get windowIds(): string[] { + return [...this.state.windowOrder] + } + + setActiveWindow(windowId: string): Promise { + return this.mutateWindowListAndPersist((state) => { + if (!isWindowId(windowId) || !Object.prototype.hasOwnProperty.call(state.windows, windowId)) { + throw new Error("Unknown client state window") + } + if (state.activeWindowId === windowId) return true + if (!this.isPrimary || this.unsupportedFutureEnvelope) return false + state.activeWindowId = windowId + }) + } + + loadClientState(windowId = this.activeWindowId): ClientStateLoadResult { + const record = this.windowRecord(windowId) if (!this.isPrimary) { - return { isPrimary: false, restoreEnabled: false, snapshot: null } + return { isPrimary: false, restoreEnabled: false, snapshot: null, partitionProtocolVersion: CLIENT_STATE_PARTITION_PROTOCOL_VERSION } } return { isPrimary: true, - restoreEnabled: this.state.restoreEnabled, - snapshot: this.state.restoreEnabled ? (this.state.snapshot ?? null) : null, + restoreEnabled: record.restoreEnabled, + snapshot: record.restoreEnabled ? (record.snapshot ?? null) : null, + partitionProtocolVersion: CLIENT_STATE_PARTITION_PROTOCOL_VERSION, } } - getWindowState(): NativeWindowState | undefined { - return this.isPrimary && !this.unsupportedFutureEnvelope && this.state.restoreEnabled ? this.state.window : undefined + loadClientStatePartition(key: unknown, rendererToken?: unknown, windowId = this.activeWindowId): Promise { + if (!isPartitionKey(key)) return Promise.reject(new TypeError("Invalid client state partition key")) + this.windowRecord(windowId) + const operation = this.writeQueue.catch(() => {}).then(async () => { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken, windowId) + const record = this.windowRecord(windowId) + if (!this.isPrimary || !record.restoreEnabled + || record.partitionProtocolVersion !== CLIENT_STATE_PARTITION_PROTOCOL_VERSION + || !record.partitionKeys?.includes(key)) return null + return this.partitions.load(key, () => this.assertReplacementAllowed(rendererToken, windowId)) + }) + this.writeQueue = operation.then(() => {}) + return operation } - claimClientStateAccess(token: unknown): true { + getWindowState(windowId = this.activeWindowId): NativeWindowState | undefined { + const record = this.windowRecord(windowId) + return this.isPrimary && !this.unsupportedFutureEnvelope && record.restoreEnabled ? record.window : undefined + } + + claimClientStateAccess(token: unknown, windowId = this.activeWindowId): true { + this.windowRecord(windowId) this.validateRendererAccessTokenValue(token) - if (this.rendererAccessToken === undefined) { - this.rendererAccessToken = token + const current = this.rendererAccessTokens.get(windowId) + if (current === undefined) { + this.rendererAccessTokens.set(windowId, token) return true } - if (this.rendererAccessToken !== token) { + if (current !== token) { throw new Error("Client state access token does not match the claimed renderer") } return true } - assertRendererAccessToken(token: unknown): void { + assertRendererAccessToken(token: unknown, windowId = this.activeWindowId): void { + this.windowRecord(windowId) this.validateRendererAccessTokenValue(token) - if (this.rendererAccessToken === undefined || this.rendererAccessToken !== token) { + if (this.rendererAccessTokens.get(windowId) !== token) { throw new Error("Client state access has not been claimed by this renderer") } } - resetRendererAccessToken(): void { - this.rendererAccessToken = undefined + resetRendererAccessToken(windowId = this.activeWindowId): void { + this.windowRecord(windowId) + this.rendererAccessTokens.delete(windowId) } - saveClientState(snapshot: unknown, rendererToken?: unknown): Promise { - const disposition = this.getMutationDisposition() + saveClientState(snapshot: unknown, rendererToken?: unknown, windowId = this.activeWindowId): Promise { + const disposition = this.getMutationDisposition(windowId) if (disposition) return disposition const serialized = JSON.stringify(snapshot) @@ -301,32 +326,52 @@ export class ClientStateManager { } const normalizedSnapshot = JSON.parse(serialized) as unknown - return this.mutateAndPersist((state) => { - state.snapshot = normalizedSnapshot - }, true, rendererToken) + return this.mutateAndPersist(windowId, (record) => { + record.snapshot = normalizedSnapshot + delete record.partitionProtocolVersion + delete record.partitionKeys + }, true, rendererToken, undefined, + () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed(rendererToken, windowId))) } - setRestoreEnabled(enabled: boolean, rendererToken?: unknown): Promise { - const disposition = this.getMutationDisposition(false) + commitClientStatePartitions(payload: unknown, rendererToken?: unknown, windowId = this.activeWindowId): Promise { + const commit = validateClientStatePartitionCommit(payload) + const disposition = this.getMutationDisposition(windowId) + if (disposition) return disposition + return this.mutateAndPersist(windowId, (record) => { + record.snapshot = commit.snapshot + record.partitionProtocolVersion = CLIENT_STATE_PARTITION_PROTOCOL_VERSION + record.partitionKeys = commit.partitionKeys + }, true, rendererToken, + () => this.partitions.prepare(commit, () => this.assertReplacementAllowed(rendererToken, windowId)), + () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed(rendererToken, windowId))) + } + + setRestoreEnabled(enabled: boolean, rendererToken?: unknown, windowId = this.activeWindowId): Promise { + const disposition = this.getMutationDisposition(windowId, false) if (disposition) return disposition if (typeof enabled !== "boolean") { throw new TypeError("Restore enabled must be a boolean") } - return this.mutateAndPersist((state) => { - state.restoreEnabled = enabled + return this.mutateAndPersist(windowId, (record) => { + record.restoreEnabled = enabled if (enabled) { - this.persistenceSuppressed = false + this.persistenceSuppressed.delete(windowId) } else { - delete state.snapshot - delete state.window - this.persistenceSuppressed = true + delete record.snapshot + delete record.window + delete record.partitionProtocolVersion + delete record.partitionKeys + this.persistenceSuppressed.add(windowId) } - }, false, rendererToken) + }, false, rendererToken, undefined, + enabled ? undefined : () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed(rendererToken, windowId))) } - clearClientState(rendererToken?: unknown): Promise { + clearClientState(rendererToken?: unknown, windowId = this.activeWindowId): Promise { + this.windowRecord(windowId) if (!this.isPrimary) { return Promise.resolve(false) } @@ -336,27 +381,63 @@ export class ClientStateManager { const clearingFutureEnvelope = this.unsupportedFutureEnvelope - return this.mutateAndPersist((state) => { - delete state.snapshot - delete state.window + return this.mutateAndPersist(windowId, (record) => { + delete record.snapshot + delete record.window + delete record.partitionProtocolVersion + delete record.partitionKeys this.unsupportedFutureEnvelope = false - this.persistenceSuppressed = !clearingFutureEnvelope - }, false, rendererToken) + if (clearingFutureEnvelope) this.persistenceSuppressed.delete(windowId) + else this.persistenceSuppressed.add(windowId) + }, false, rendererToken, undefined, + () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed(rendererToken, windowId))) } - saveWindowState(windowState: NativeWindowState): Promise { - const disposition = this.getMutationDisposition() + saveWindowState(windowState: NativeWindowState, windowId = this.activeWindowId): Promise { + const disposition = this.getMutationDisposition(windowId) if (disposition) return disposition const normalized = normalizeNativeWindowState(windowState) if (!normalized) { return Promise.resolve(false) } - return this.mutateAndPersist((state) => { - state.window = normalized + return this.mutateAndPersist(windowId, (record) => { + record.window = normalized }, true) } + addWindow(windowId = randomUUID()): Promise { + return this.mutateWindowListAndPersist((state) => { + if (!isWindowId(windowId)) throw new TypeError("Invalid client state window ID") + if (state.windowOrder.length >= MAX_CLIENT_STATE_WINDOWS) throw new RangeError("Too many client state windows") + if (state.windows[windowId]) throw new Error("Client state window already exists") + if (!this.isPrimary || this.unsupportedFutureEnvelope) return false + if (state.windowOrder.length === 0) state.activeWindowId = windowId + state.windowOrder.push(windowId) + state.windows[windowId] = { restoreEnabled: true } + }).then((persisted) => persisted ? windowId : null) + } + + removeWindow(windowId: string): Promise { + return this.mutateWindowListAndPersist((state) => { + if (!isWindowId(windowId) || !Object.prototype.hasOwnProperty.call(state.windows, windowId)) { + throw new Error("Unknown client state window") + } + if (!this.isPrimary || this.unsupportedFutureEnvelope) return false + state.windowOrder = state.windowOrder.filter((id) => id !== windowId) + delete state.windows[windowId] + if (state.activeWindowId === windowId && state.windowOrder.length > 0) state.activeWindowId = state.windowOrder[0]! + }, + () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed())) + .then((removed) => { + if (removed) { + this.rendererAccessTokens.delete(windowId) + this.persistenceSuppressed.delete(windowId) + } + return removed + }) + } + async flush(): Promise { await this.writeQueue } @@ -367,6 +448,7 @@ export class ClientStateManager { } this.frozen = true + // The primary locks stay held until the queued root publication and GC have both drained. this.drainAndReleasePromise = this.writeQueue.finally(() => { this.primary = false this.releaseOwnedProcessFiles() @@ -378,12 +460,18 @@ export class ClientStateManager { try { return parseClientState(readFileSync(this.statePath, "utf8")) } catch (error) { - if (!hasErrorCode(error, "ENOENT")) { - console.warn("[client-state] failed to read state", error) + if (hasErrorCode(error, "ENOENT")) { + return { + state: createClientState(), + unsupportedFutureEnvelope: false, + } } + console.warn("[client-state] failed to read state", error) + const state = createClientState() + state.windows[state.activeWindowId]!.restoreEnabled = false return { - state: { version: CLIENT_STATE_VERSION, restoreEnabled: true }, - unsupportedFutureEnvelope: false, + state, + unsupportedFutureEnvelope: true, } } } @@ -422,6 +510,7 @@ export class ClientStateManager { descriptor = undefined this.assertReplacementAllowed() renameSync(temporaryPath, this.statePath) + this.syncStateDirectorySync() } finally { if (descriptor !== undefined) closeSync(descriptor) rm(temporaryPath, { force: true }).catch(() => {}) @@ -444,6 +533,13 @@ export class ClientStateManager { if (hasErrorCode(error, "ENOENT")) return throw error } + try { + const serialized = bytes.toString("utf8") + const candidate = JSON.parse(serialized) as Record + if (candidate?.version !== CLIENT_STATE_MONOLITHIC_VERSION || parseClientState(serialized).unsupportedFutureEnvelope) return + } catch { + return + } const temporaryPath = join(dirname(this.statePath), `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.shared-migration.tmp`) let descriptor: number | undefined @@ -456,6 +552,7 @@ export class ClientStateManager { this.assertReplacementAllowed() try { linkSync(temporaryPath, this.statePath) + this.syncStateDirectorySync() } catch (error) { if (!hasErrorCode(error, "EEXIST")) throw error } @@ -465,7 +562,15 @@ export class ClientStateManager { } } - private getMutationDisposition(futureEnvelopeResult = true): Promise | undefined { + private windowRecord(windowId: string): ClientWindowStateRecord { + if (!isWindowId(windowId) || !Object.prototype.hasOwnProperty.call(this.state.windows, windowId)) { + throw new Error("Unknown client state window") + } + return this.state.windows[windowId]! + } + + private getMutationDisposition(windowId: string, futureEnvelopeResult = true): Promise | undefined { + this.windowRecord(windowId) if (!this.isPrimary) { return Promise.resolve(false) } @@ -479,50 +584,91 @@ export class ClientStateManager { } private mutateAndPersist( - mutate: (state: PersistedClientState) => void, + windowId: string, + mutate: (record: ClientWindowStateRecord, state: PersistedClientState) => void, skipWhenSuppressed = false, rendererToken?: unknown, + prepare?: () => Promise, + committed?: () => Promise, ): Promise { const operation = this.writeQueue.catch(() => {}).then(async () => { - if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken) - if (skipWhenSuppressed && this.persistenceSuppressed) { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken, windowId) + if (skipWhenSuppressed && this.persistenceSuppressed.has(windowId)) { return } - const previousState = { ...this.state } - const previousPersistenceSuppressed = this.persistenceSuppressed + const previousState = JSON.parse(JSON.stringify(this.state)) as PersistedClientState + const previousPersistenceSuppressed = new Set(this.persistenceSuppressed) const previousUnsupportedFutureEnvelope = this.unsupportedFutureEnvelope try { - mutate(this.state) - await this.writeAtomically(JSON.stringify(this.state), rendererToken) + await prepare?.() + mutate(this.windowRecord(windowId), this.state) + await this.writeAtomically(JSON.stringify(this.state), rendererToken, windowId) } catch (error) { this.state = previousState this.persistenceSuppressed = previousPersistenceSuppressed this.unsupportedFutureEnvelope = previousUnsupportedFutureEnvelope throw error } + await committed?.().catch((error) => console.warn("[client-state] failed to sweep partitions", error)) }) this.writeQueue = operation return operation.then(() => true) } - private async writeAtomically(serializedState: string, rendererToken?: unknown): Promise { + private mutateWindowListAndPersist( + mutate: (state: PersistedClientState) => boolean | void, + committed?: () => Promise, + ): Promise { + if (this.frozen) return Promise.reject(new Error("Client state persistence is frozen for shutdown")) + const operation = this.writeQueue.catch(() => {}).then(async () => { + const previousState = JSON.parse(JSON.stringify(this.state)) as PersistedClientState + try { + const result = mutate(this.state) + if (result !== undefined) return result + await this.writeAtomically(JSON.stringify(this.state)) + } catch (error) { + this.state = previousState + throw error + } + await committed?.().catch((error) => console.warn("[client-state] failed to sweep partitions", error)) + return true + }) + this.writeQueue = operation.then(() => {}) + return operation + } + + private async writeAtomically(serializedState: string, rendererToken?: unknown, windowId = this.activeWindowId): Promise { const temporaryPath = join( dirname(this.statePath), `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.tmp`, ) try { await this.writeState(temporaryPath, serializedState) - this.assertReplacementAllowed(rendererToken) + this.assertReplacementAllowed(rendererToken, windowId) await rename(temporaryPath, this.statePath) + await syncDirectory(dirname(this.statePath)) } catch (error) { await rm(temporaryPath, { force: true }).catch(() => {}) throw error } } - private assertReplacementAllowed(rendererToken?: unknown): void { - if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken) + private syncStateDirectorySync(): void { + let descriptor: number | undefined + try { + descriptor = openSync(dirname(this.statePath), "r") + fsyncSync(descriptor) + } catch (error) { + if (process.platform === "win32" && (hasErrorCode(error, "EISDIR") || hasErrorCode(error, "EPERM") || hasErrorCode(error, "EINVAL"))) return + throw error + } finally { + if (descriptor !== undefined) closeSync(descriptor) + } + } + + private assertReplacementAllowed(rendererToken?: unknown, windowId = this.activeWindowId): void { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken, windowId) if (!this.isPrimary || !isProcessOwnerLockOwned(this.lockPath, this.owner)) { throw new Error("Client state ownership changed before atomic replacement") } diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 2f2bd147..45035d10 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,11 +1,23 @@ -import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron" -import fs from "fs" +import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type IpcMainInvokeEvent, type OpenDialogOptions } from "electron" +import fs from "node:fs" import { requestMicrophoneAccess } from "./permissions" -import type { CliProcessManager, CliStatus } from "./process-manager" +import type { CliProcessManager } from "./process-manager" import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open" import { setWorkspaceMenuEnabled } from "./menu" -let wakeLockId: number | null = null +interface LocalSender { + id: string + window: BrowserWindow +} + +interface CliIPCDependencies { + resolveLocal(sender: IpcMainInvokeEvent["sender"]): LocalSender | 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 +} interface DialogOpenRequest { mode: "directory" | "file" @@ -20,220 +32,156 @@ interface DialogOpenResult { paths: string[] } -async function resolveLocalWorkspaceFolder( - mainWindow: BrowserWindow, - cliManager: CliProcessManager, - instanceId: string, - worktreeSlug: string, -): Promise { +function validateMainFrame(event: IpcMainInvokeEvent, window: BrowserWindow, allowedOrigins: string[]): void { + if (window.isDestroyed() || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) { + throw new Error("Native IPC requires a registered main frame") + } + const current = new URL(window.webContents.getURL()) + const sender = new URL(event.senderFrame.url) + if (current.origin !== sender.origin || !allowedOrigins.includes(sender.origin)) { + throw new Error("Native IPC requires an allowed renderer origin") + } +} + +async function resolveLocalWorkspaceFolder(window: BrowserWindow, cliManager: CliProcessManager, instanceId: string, worktreeSlug: string): Promise { const baseUrl = cliManager.getStatus().url if (!baseUrl) throw new Error("Local CodeNomad server is unavailable") const cookieName = cliManager.getAuthCookieName() - const cookie = (await mainWindow.webContents.session.cookies.get({ url: baseUrl, name: cookieName }))[0] + const cookie = (await window.webContents.session.cookies.get({ url: baseUrl, name: cookieName }))[0] const headers = cookie ? { Cookie: `${cookie.name}=${cookie.value}` } : undefined - const workspaceResponse = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}`, { - headers, - signal: AbortSignal.timeout(5_000), - }) + const workspaceResponse = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}`, { headers, signal: AbortSignal.timeout(5_000) }) if (!workspaceResponse.ok) throw new Error("Workspace is not active") const workspace = await workspaceResponse.json() as { path?: unknown } if (typeof workspace.path !== "string") throw new Error("Workspace path is unavailable") if (worktreeSlug === "root") return workspace.path - - const worktreeResponse = await fetch( - `${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`, - { headers, signal: AbortSignal.timeout(5_000) }, - ) - if (!worktreeResponse.ok) throw new Error("Workspace worktrees are unavailable") - const payload = await worktreeResponse.json() as { worktrees?: Array<{ slug?: unknown; directory?: unknown }> } + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`, { headers, signal: AbortSignal.timeout(5_000) }) + if (!response.ok) throw new Error("Workspace worktrees are unavailable") + const payload = await response.json() as { worktrees?: Array<{ slug?: unknown; directory?: unknown }> } const worktree = payload.worktrees?.find((candidate) => candidate.slug === worktreeSlug) if (!worktree || typeof worktree.directory !== "string") throw new Error("Selected worktree is unavailable") return worktree.directory } -export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) { - cliManager.on("status", (status: CliStatus) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send("cli:status", status) +export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCDependencies) { + let wakeLockId: number | null = null + const wakeLockWindows = new Set() + const wakeCleanupInstalled = new Set() + const local = (event: IpcMainInvokeEvent): LocalSender => { + const record = dependencies.resolveLocal(event.sender) + if (!record) throw new Error("Native operation is limited to local windows") + validateMainFrame(event, record.window, dependencies.getAllowedOrigins(record.window)) + return record + } + const anyTrusted = (event: IpcMainInvokeEvent): BrowserWindow => { + const window = BrowserWindow.fromWebContents(event.sender) + if (!window) throw new Error("Native operation requires a window") + validateMainFrame(event, window, dependencies.getAllowedOrigins(window)) + return window + } + const updateWakeLock = (): boolean => { + if (wakeLockWindows.size > 0) { + if (wakeLockId === null || !powerSaveBlocker.isStarted(wakeLockId)) wakeLockId = powerSaveBlocker.start("prevent-app-suspension") + return true } - }) + if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) powerSaveBlocker.stop(wakeLockId) + wakeLockId = null + return false + } - cliManager.on("ready", (status: CliStatus) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send("cli:ready", status) - } - }) - - cliManager.on("error", (error: Error) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send("cli:error", { message: error.message }) - } - }) - - ipcMain.handle("cli:getStatus", async () => cliManager.getStatus()) - - ipcMain.handle("cli:restart", async () => { - const devMode = process.env.NODE_ENV === "development" - return cliManager.restart({ dev: devMode }) - }) - - ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise => { - const properties: OpenDialogOptions["properties"] = - request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"] - if (request.mode === "file" && request.multiple) { - properties.push("multiSelections") - } - - const filters = request.filters?.map((filter) => ({ - name: filter.name ?? "Files", - extensions: filter.extensions, - })) - - const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow - const dialogOptions: OpenDialogOptions = { - title: request.title, - defaultPath: request.defaultPath, - properties, - filters, - } - const result = windowTarget - ? await dialog.showOpenDialog(windowTarget, dialogOptions) - : await dialog.showOpenDialog(dialogOptions) - - return { canceled: result.canceled, paths: result.filePaths } - }) - - ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise => { - if (!Array.isArray(paths)) { - return [] - } - - const directories = paths.filter((value): value is string => { - if (typeof value !== "string" || value.trim().length === 0) { - return false - } - try { - return fs.statSync(value).isDirectory() - } catch { - return false - } - }) - return directories - }) - - ipcMain.handle( - "workspace:openTarget", - async (event, payload: { target?: unknown; instanceId?: unknown; worktreeSlug?: unknown; path?: unknown; editor?: unknown }): Promise<{ ok: true }> => { - if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) { - throw new Error("Workspace open requests are limited to the local main window") - } - const localUrl = cliManager.getStatus().url - if (!localUrl || new URL(event.senderFrame.url).origin !== new URL(localUrl).origin) { - throw new Error("Workspace open requests require the local CodeNomad origin") - } - const target = payload?.target - const instanceId = payload?.instanceId - const worktreeSlug = payload?.worktreeSlug - const editor = payload?.editor - if ( - (target !== "default" && target !== "reveal" && target !== "terminal" && target !== "editor") - || typeof instanceId !== "string" - || typeof worktreeSlug !== "string" - || (payload.path !== undefined && typeof payload.path !== "string") - || (editor !== undefined && editor !== "vscode" && editor !== "cursor" && editor !== "zed" && editor !== "vscodium") - ) { - throw new Error("Invalid workspace open request") - } - const workspaceFolder = await resolveLocalWorkspaceFolder(mainWindow, cliManager, instanceId, worktreeSlug) - await openWorkspaceTarget( - target as WorkspaceOpenTarget, - workspaceFolder, - payload.path as string | undefined, - editor as WorkspaceEditor | undefined, - { openPath: (path) => shell.openPath(path), revealPath: (path) => shell.showItemInFolder(path) }, - ) - return { ok: true } - }, - ) - - ipcMain.handle("workspace:setMenuEnabled", (event, enabled: unknown): { ok: true } => { - if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) { - throw new Error("Workspace menu updates are limited to the local main window") - } - setWorkspaceMenuEnabled(enabled === true) + ipcMain.handle("cli:getStatus", async (event) => { local(event); return cliManager.getStatus() }) + ipcMain.handle("cli:restart", async (event) => { local(event); return cliManager.restart({ dev: process.env.NODE_ENV === "development" }) }) + ipcMain.handle("window:new", async (event) => { local(event); await dependencies.newWindow(); return { ok: true } }) + ipcMain.handle("window:nextFolder", async (event) => dependencies.nextFolder(local(event).id)) + ipcMain.handle("window:ackFolder", async (event, folder: unknown, opened: unknown) => { + const { id } = local(event) + if (typeof folder !== "string" || typeof opened !== "boolean") throw new Error("Invalid folder acknowledgement") + dependencies.acknowledgeFolder(id, folder, opened) return { ok: true } }) - ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => { - const next = Boolean(enabled) - if (next) { - if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) { - return { enabled: true } - } - try { - wakeLockId = powerSaveBlocker.start("prevent-app-suspension") - } catch { - wakeLockId = null - return { enabled: false } - } - return { enabled: true } - } - - if (wakeLockId !== null) { - try { - if (powerSaveBlocker.isStarted(wakeLockId)) { - powerSaveBlocker.stop(wakeLockId) - } - } finally { - wakeLockId = null - } - } - return { enabled: false } + ipcMain.handle("dialog:open", async (event, request: DialogOpenRequest): Promise => { + const { window } = local(event) + if (!request || (request.mode !== "directory" && request.mode !== "file")) throw new Error("Invalid dialog request") + const properties: OpenDialogOptions["properties"] = request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"] + if (request.mode === "file" && request.multiple) properties.push("multiSelections") + const result = await dialog.showOpenDialog(window, { + title: request.title, + defaultPath: request.defaultPath, + properties, + filters: request.filters?.map((filter) => ({ name: filter.name ?? "Files", extensions: filter.extensions })), + }) + return { canceled: result.canceled, paths: result.filePaths } }) - ipcMain.handle( - "media:requestMicrophoneAccess", - async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }), - ) + ipcMain.handle("filesystem:getDirectoryPaths", async (event, paths: unknown): Promise => { + local(event) + if (!Array.isArray(paths)) return [] + return paths.filter((value): value is string => { + if (typeof value !== "string" || !value.trim()) return false + try { return fs.statSync(value).isDirectory() } catch { return false } + }) + }) - ipcMain.handle( - "remote:openWindow", - async ( - _event, - payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }, - ): Promise<{ ok: boolean }> => { - const opener = (mainWindow as BrowserWindow & { - __codenomadOpenRemoteWindow?: (payload: { - id: string - name: string - baseUrl: string - skipTlsVerify: boolean - }) => Promise - }).__codenomadOpenRemoteWindow - if (!opener) { - throw new Error("Remote window opening is not available") - } - await opener(payload) + ipcMain.handle("workspace:openTarget", async (event, payload: { target?: unknown; instanceId?: unknown; worktreeSlug?: unknown; path?: unknown; editor?: unknown }): Promise<{ ok: true }> => { + const { window } = local(event) + const { target, instanceId, worktreeSlug, editor } = payload ?? {} + if ((target !== "default" && target !== "reveal" && target !== "terminal" && target !== "editor") + || typeof instanceId !== "string" || typeof worktreeSlug !== "string" + || (payload?.path !== undefined && typeof payload.path !== "string") + || (editor !== undefined && editor !== "vscode" && editor !== "cursor" && editor !== "zed" && editor !== "vscodium")) { + throw new Error("Invalid workspace open request") + } + const folder = await resolveLocalWorkspaceFolder(window, cliManager, instanceId, worktreeSlug) + await openWorkspaceTarget(target as WorkspaceOpenTarget, folder, payload?.path as string | undefined, editor as WorkspaceEditor | undefined, { + openPath: (path) => shell.openPath(path), revealPath: (path) => shell.showItemInFolder(path), + }) + return { ok: true } + }) + + ipcMain.handle("workspace:setMenuEnabled", (event, enabled: unknown): { ok: true } => { + setWorkspaceMenuEnabled(local(event).window, enabled === true) + return { ok: true } + }) + + ipcMain.handle("power:setWakeLock", async (event, enabled: boolean): Promise<{ enabled: boolean }> => { + const window = anyTrusted(event) + const id = window.webContents.id + if (!wakeCleanupInstalled.has(id)) { + wakeCleanupInstalled.add(id) + window.webContents.once("destroyed", () => { + wakeLockWindows.delete(id) + wakeCleanupInstalled.delete(id) + updateWakeLock() + }) + } + if (enabled) wakeLockWindows.add(id) + else wakeLockWindows.delete(id) + try { return { enabled: updateWakeLock() } } catch { return { enabled: false } } + }) + + ipcMain.handle("media:requestMicrophoneAccess", async (event): Promise<{ granted: boolean }> => { + 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 }) => { + local(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") + } + 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" } + try { + new Notification({ title: typeof payload?.title === "string" ? payload.title : "CodeNomad", body: typeof payload?.body === "string" ? payload.body : "" }).show() return { ok: true } - }, - ) - - ipcMain.handle( - "notifications:show", - async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { - if (!Notification.isSupported()) { - return { ok: false, reason: "unsupported" } - } - - const title = typeof payload?.title === "string" ? payload.title : "CodeNomad" - const body = typeof payload?.body === "string" ? payload.body : "" - try { - const notification = new Notification({ title, body }) - notification.show() - return { ok: true } - } catch (error) { - return { ok: false, reason: error instanceof Error ? error.message : String(error) } - } - }, - ) + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) } + } + }) } diff --git a/packages/electron-app/electron/main/local-window-registry.test.ts b/packages/electron-app/electron/main/local-window-registry.test.ts new file mode 100644 index 00000000..724859e1 --- /dev/null +++ b/packages/electron-app/electron/main/local-window-registry.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { LocalWindowRegistry } from "./local-window-registry" + +const id1 = "11111111-1111-4111-8111-111111111111" +const id2 = "22222222-2222-4222-8222-222222222222" + +function fakeWindow(contentsId: number) { + const calls: string[] = [] + const webContents = { id: contentsId, isDestroyed: () => false, send: (channel: string) => calls.push(channel) } + return { calls, webContents, isDestroyed: () => false, isMinimized: () => false, show: () => calls.push("show"), focus: () => calls.push("focus"), restore: () => calls.push("restore") } +} + +test("registry resolves independent webContents, tracks MRU, queues folders, and fans out backend events", () => { + const active: string[] = [] + const registry = new LocalWindowRegistry((id) => { active.push(id) }) + const first = fakeWindow(1) + const second = fakeWindow(2) + registry.add({ id: id1.toUpperCase(), persisted: true, window: first as never, navigation: {} as never, tracker: null, loading: true, backendUrl: null, pendingFolders: [] }) + registry.add({ id: id2, persisted: true, window: second as never, navigation: {} as never, tracker: null, loading: true, backendUrl: null, pendingFolders: [] }) + assert.equal(registry.resolve(first.webContents as never)?.id, id1) + assert.equal(registry.focusMru()?.id, id2) + registry.focus(id1) + assert.equal(registry.focusMru()?.id, id1) + assert.deepEqual(active, [id2, id1, id1]) + registry.queueFolder(id2, "/two") + registry.queueFolder(id2, "/three") + assert.equal(registry.nextFolder(id2), "/two") + registry.acknowledgeFolder(id2, "/two", false) + assert.equal(registry.nextFolder(id2), "/three") + assert.throws(() => registry.acknowledgeFolder(id2, "/wrong", true), /out of order/) + registry.acknowledgeFolder(id2, "/three", true) + assert.equal(registry.nextFolder(id2), "/two") + registry.acknowledgeFolder(id2, "/two", false) + registry.acknowledgeFolder(id2, "/two", false) + assert.equal(registry.nextFolder(id2), null) + registry.fanout("cli:ready", {}) + assert.equal(first.calls.includes("cli:ready"), true) + assert.equal(second.calls.includes("cli:ready"), true) + registry.remove(id1) + assert.equal(registry.resolve(first.webContents as never), undefined) +}) diff --git a/packages/electron-app/electron/main/local-window-registry.ts b/packages/electron-app/electron/main/local-window-registry.ts new file mode 100644 index 00000000..d2b08ec3 --- /dev/null +++ b/packages/electron-app/electron/main/local-window-registry.ts @@ -0,0 +1,130 @@ +import type { BrowserWindow, WebContents } from "electron" +import type { ClientStateNavigationController } from "./client-state-navigation" +import type { WindowStateTracker } from "./window-state" + +export interface LocalWindowRecord { + id: string + persisted: boolean + window: BrowserWindow + navigation: ClientStateNavigationController + tracker: WindowStateTracker | null + loading: boolean + backendUrl: string | null + pendingFolders: PendingFolder[] +} + +interface PendingFolder { path: string; attempts: number } + +const MAX_FOLDER_ATTEMPTS = 3 + +export class LocalWindowRegistry { + private readonly records = new Map() + private readonly webContentsIds = new Map() + private mru: string[] = [] + + constructor(private readonly setActiveWindow: (id: string) => void | Promise) {} + + add(record: LocalWindowRecord): void { + const id = record.id.toLowerCase() + if (this.records.has(id)) throw new Error("Local window is already registered") + if (this.records.size >= 16) throw new RangeError("Too many local windows") + record.id = id + this.records.set(id, record) + this.webContentsIds.set(record.window.webContents.id, id) + this.mru = [id, ...this.mru.filter((candidate) => candidate !== id)] + } + + get(id: string): LocalWindowRecord | undefined { + return this.records.get(id.toLowerCase()) + } + + all(): LocalWindowRecord[] { + return [...this.records.values()] + } + + resolve(sender: WebContents): LocalWindowRecord | undefined { + const id = this.webContentsIds.get(sender.id) + return id ? this.records.get(id) : undefined + } + + focus(id: string): LocalWindowRecord | undefined { + const record = this.get(id) + if (!record || record.window.isDestroyed()) return undefined + this.markFocused(record.id) + if (record.window.isMinimized()) record.window.restore() + record.window.show() + record.window.focus() + return record + } + + markFocused(id: string): void { + const record = this.get(id) + if (!record) return + this.mru = [record.id, ...this.mru.filter((candidate) => candidate !== record.id)] + if (!record.persisted) return + try { + void Promise.resolve(this.setActiveWindow(record.id)).catch((error) => console.warn("[client-state] failed to persist active window", error)) + } catch (error) { + console.warn("[client-state] failed to persist active window", error) + } + } + + mruRecord(): LocalWindowRecord | undefined { + for (const id of this.mru) { + const record = this.get(id) + if (record && !record.window.isDestroyed()) return record + } + return undefined + } + + focusMru(): LocalWindowRecord | undefined { + for (const id of this.mru) { + const focused = this.focus(id) + if (focused) return focused + } + return undefined + } + + queueFolder(id: string, folder: string): void { + const record = this.get(id) + if (!record) throw new Error("Unknown local window") + record.pendingFolders.push({ path: folder, attempts: 0 }) + if (!record.window.isDestroyed() && !record.window.webContents.isDestroyed()) { + record.window.webContents.send("window:folders-pending") + } + } + + nextFolder(id: string): string | null { + const record = this.get(id) + if (!record) throw new Error("Unknown local window") + return record.pendingFolders[0]?.path ?? null + } + + acknowledgeFolder(id: string, folder: string, opened: boolean): void { + const record = this.get(id) + if (!record) throw new Error("Unknown local window") + const pending = record.pendingFolders.shift() + if (pending?.path !== folder) { + if (pending) record.pendingFolders.unshift(pending) + throw new Error("Pending folder acknowledgement is out of order") + } + if (!opened && ++pending.attempts < MAX_FOLDER_ATTEMPTS) record.pendingFolders.push(pending) + } + + remove(id: string): LocalWindowRecord | undefined { + const record = this.get(id) + if (!record) return undefined + this.records.delete(record.id) + this.webContentsIds.delete(record.window.webContents.id) + this.mru = this.mru.filter((candidate) => candidate !== record.id) + return record + } + + fanout(channel: string, payload: unknown): void { + for (const record of this.records.values()) { + if (!record.window.isDestroyed() && !record.window.webContents.isDestroyed()) { + record.window.webContents.send(channel, payload) + } + } + } +} diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index f90fb361..4dcf513f 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,840 +1,345 @@ -import { app, BrowserView, BrowserWindow, ipcMain, nativeImage, screen, session, shell } from "electron" +import { app, BrowserWindow, ipcMain, nativeImage, screen, session, shell } from "electron" import http from "node:http" import https from "node:https" -import { existsSync, mkdirSync, rmSync } from "fs" -import { dirname, join } from "path" -import { fileURLToPath } from "url" -import { createApplicationMenu } from "./menu" +import { existsSync, mkdirSync, rmSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" import { ClientStateManager } from "./client-state" import { setupClientStateIPC } from "./client-state-ipc" -import { ClientStateLifecycle } from "./client-state-lifecycle" import { ClientStateNavigationController } from "./client-state-navigation" import { setupCliIPC } from "./ipc" +import { LocalWindowRegistry, type LocalWindowRecord } from "./local-window-registry" +import { clearWorkspaceMenuWindow, createApplicationMenu, setWorkspaceMenuEnabled } from "./menu" +import { resolveFocusedLocalTarget, resolveWindowTarget } from "./menu-target" +import { MultiwindowLifecycle } from "./multiwindow-lifecycle" import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions" -import { resolveConfiguredRendererOrigins } from "./renderer-origin" import { CliProcessManager } from "./process-manager" -import { - clampWindowBounds, - DEFAULT_WINDOW_HEIGHT, - DEFAULT_WINDOW_WIDTH, - installWindowZoomInput, - restoreWindowState, - WindowStateTracker, -} from "./window-state" - -const mainFilename = fileURLToPath(import.meta.url) -const mainDirname = dirname(mainFilename) +import { RemoteWindowRegistry } from "./remote-window-registry" +import { resolveConfiguredRendererOrigins } from "./renderer-origin" +import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, 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)) const isMac = process.platform === "darwin" -function configureDevStoragePaths() { - if (app.isPackaged) { - return - } - - const appName = "CodeNomad" - - try { - app.setName(appName) - - const userDataPath = join(app.getPath("appData"), appName) - const sessionDataPath = join(userDataPath, "session-data") - - mkdirSync(userDataPath, { recursive: true }) - mkdirSync(sessionDataPath, { recursive: true }) - - app.setPath("userData", userDataPath) - app.setPath("sessionData", sessionDataPath) - } catch (error) { - console.warn("[cli] failed to configure dev storage paths", error) - } +function configureStoragePaths() { + const baseUserDataPath = app.isPackaged ? app.getPath("userData") : join(app.getPath("appData"), "CodeNomad") + if (!app.isPackaged) app.setName("CodeNomad") + const scope = resolveStorageScope({ + appVersion: app.getVersion(), environmentChannel: process.env.CODENOMAD_UPDATE_CHANNEL, + cliConfig: process.env.CLI_CONFIG, cwd: process.cwd(), baseUserDataPath, packaged: app.isPackaged, + }) + mkdirSync(scope.userDataPath, { recursive: true }) + mkdirSync(scope.sessionDataPath, { recursive: true }) + app.setPath("userData", scope.userDataPath) + app.setPath("sessionData", scope.sessionDataPath) + return scope } -configureDevStoragePaths() - -function configurePackagedStoragePaths() { - if (!app.isPackaged) { - return - } - - try { - const sessionDataPath = join(app.getPath("userData"), "session-data-v2") - mkdirSync(sessionDataPath, { recursive: true }) - app.setPath("sessionData", sessionDataPath) - } catch (error) { - console.warn("[electron-startup] failed to configure packaged session data path", error) - } -} - -configurePackagedStoragePaths() - function cleanupPackagedChromiumStorage() { - if (!app.isPackaged) { - return - } - - const roots = [app.getPath("sessionData"), app.getPath("userData"), join(app.getPath("userData"), "session-data")] - const names = ["Service Worker", "QuotaManager", "QuotaManager-journal"] - - for (const root of roots) { - for (const name of names) { + if (!app.isPackaged) return + for (const root of [app.getPath("sessionData"), app.getPath("userData"), join(app.getPath("userData"), "session-data")]) { + for (const name of ["Service Worker", "QuotaManager", "QuotaManager-journal"]) { const candidate = join(root, name) - if (!existsSync(candidate)) { - continue - } - - try { - rmSync(candidate, { recursive: true, force: true }) - console.info("[electron-startup] removed stale Chromium storage", candidate) - } catch (error) { - console.warn("[electron-startup] failed to remove stale Chromium storage", candidate, error) - } + if (!existsSync(candidate)) continue + try { rmSync(candidate, { recursive: true, force: true }) } catch (error) { console.warn("[electron-startup] failed to remove stale Chromium storage", candidate, error) } } } } -cleanupPackagedChromiumStorage() - -const clientStateManager = new ClientStateManager(app.getPath("userData")) -const cliManager = new CliProcessManager() -let mainWindow: BrowserWindow | null = null -let currentCliUrl: string | null = null -let pendingCliUrl: string | null = null -let pendingBootstrapToken: string | null = null -let showingLoadingScreen = false -let preloadingView: BrowserView | null = null -let mainNavigationController: ClientStateNavigationController | null = null -const remoteWindowOrigins = new Map>() -const insecureWindowOrigins = new Map>() -const clientStateLifecycle = new ClientStateLifecycle({ - app, - clientStateManager, - cliManager, - getMainWindow: () => mainWindow, - getAllWindows: () => BrowserWindow.getAllWindows(), - getAllowedRendererOrigins, - isTrustedRendererOrigin: isAllowedRendererOrigin, -}) -const bindClientStateWindow = setupClientStateIPC( - ipcMain, - clientStateManager, - () => mainWindow, - getAllowedRendererOrigins, -) - -if (isMac) { - app.commandLine.appendSwitch("disable-spell-checking") +function argvForLaunch(argv: string[]): string[] { + return argv.slice(app.isPackaged ? 1 : 2) } -function getIconPath() { - if (app.isPackaged) { - return join(process.resourcesPath, "icon.png") +const storageScope = configureStoragePaths() +const initialIntent = parseLaunchIntent(argvForLaunch(process.argv), process.cwd()) +startPrimaryInstance(() => app.requestSingleInstanceLock(), () => app.quit(), () => runPrimary(initialIntent)) + +function runPrimary(firstIntent: LaunchIntent) { + cleanupPackagedChromiumStorage() + const clientState = new ClientStateManager(app.getPath("userData"), undefined, storageScope.clientStateElectionDirectory + ? { crossHostElectionDirectory: storageScope.clientStateElectionDirectory } + : undefined) + const cli = new CliProcessManager() + const registry = new LocalWindowRegistry(async (id) => { await clientState.setActiveWindow(id) }) + const remoteOrigins = new Map>() + const insecureOrigins = new Map>() + 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 getAllowedOrigins = (window?: BrowserWindow | null): string[] => { + 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] + } + const lifecycle = new MultiwindowLifecycle({ + app, clientStateManager: clientState, cliManager: cli, + getLocalWindows: () => registry.all(), getAllWindows: () => BrowserWindow.getAllWindows(), + removeWindowState: (id) => clientState.removeWindow(id), getAllowedRendererOrigins: getAllowedOrigins, + isTrustedRendererOrigin: isAllowedRendererOrigin, + }) + const bindClientState = setupClientStateIPC(ipcMain, clientState, (sender) => registry.resolve(sender), getAllowedOrigins) + + const loadingTarget = (): { url?: string; file?: string } => { + if (!app.isPackaged) { + const base = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL + if (base) return { url: new URL("loading.html", base.endsWith("/") ? base : `${base}/`).toString() } + } + const candidates = [join(app.getAppPath(), "dist/renderer/loading.html"), join(process.resourcesPath, "dist/renderer/loading.html"), join(mainDirname, "../dist/renderer/loading.html")] + return { file: candidates.find(existsSync) ?? candidates[0] } + } + const loadLoading = async (record: LocalWindowRecord, force = false) => { + if (record.window.isDestroyed() || (record.loading && !force)) return + record.loading = true + record.backendUrl = null + remoteOrigins.delete(record.window.id) + const target = loadingTarget() + await record.navigation.navigate((window) => target.url ? window.loadURL(target.url) : window.loadFile(target.file!)).catch((error) => { + if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load loading screen", error) + }) + } + const navigateBackend = async (record: LocalWindowRecord, url: string) => { + if (record.window.isDestroyed() || (!record.loading && record.backendUrl === url)) return + let origin: string + try { origin = new URL(url).origin } catch { return } + const previous = remoteOrigins.get(record.window.id) + remoteOrigins.set(record.window.id, new Set([...(previous ?? []), origin])) + await record.navigation.navigate((window) => window.loadURL(url)).then(() => { + record.loading = false + record.backendUrl = url + remoteOrigins.set(record.window.id, new Set([origin])) + }, (error) => { + if (previous) remoteOrigins.set(record.window.id, previous); else remoteOrigins.delete(record.window.id) + if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load backend", error) + }) + } + const bootstrap = new BackendBootstrapCoordinator( + (url, token) => exchangeBootstrapToken(url, token, cli), + (url) => { + backendTargetUrl = url + for (const record of registry.all()) void navigateBackend(record, url) + }, + (error) => console.error("[cli] bootstrap token exchange failed", error), + ) + + const createWindow = (windowId: string, persisted = true): LocalWindowRecord => { + const saved = persisted ? clientState.getWindowState(windowId) : undefined + const bounds = saved ? clampWindowBounds(saved.bounds, screen.getAllDisplays().map((display) => ({ ...display.workArea, scaleFactor: display.scaleFactor }))) : undefined + const window = new BrowserWindow({ + width: bounds?.width ?? DEFAULT_WINDOW_WIDTH, height: bounds?.height ?? DEFAULT_WINDOW_HEIGHT, + ...(bounds ? { x: bounds.x, y: bounds.y } : {}), useContentSize: true, minWidth: 800, minHeight: 600, + backgroundColor: "#1a1a1a", icon: getIconPath(), + webPreferences: { + preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, + ...(saved ? { zoomFactor: saved.zoomFactor } : {}), + additionalArguments: ["--codenomad-window-context=local", `--codenomad-window-id=${windowId}`], + }, + }) + const navigation = new ClientStateNavigationController(window, { + clientStateManager: persisted ? clientState : { isPrimary: false }, + isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedOrigins(window)), + reportFlushError: (error) => console.warn("[client-state] renderer pre-navigation flush failed", error), + }) + const tracker = persisted && clientState.isPrimary ? new WindowStateTracker(window, clientState, saved, windowId) : null + if (persisted && clientState.isPrimary) restoreWindowState(window, saved, bounds) + const record: LocalWindowRecord = { id: windowId, persisted, window, navigation, tracker, loading: false, backendUrl: null, pendingFolders: [] } + registry.add(record) + bindClientState(window) + lifecycle.attach(record) + installWindowZoomInput(window, (level) => tracker ? tracker.setZoomLevel(level) : window.webContents.setZoomLevel(level)) + setupNavigationGuards(window, navigation, getAllowedOrigins) + window.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => { + if (isMainFrame) setWorkspaceMenuEnabled(window, false) + }) + window.on("focus", () => registry.markFocused(windowId)) + window.on("closed", () => { + registry.remove(windowId) + clearWorkspaceMenuWindow(window) + remoteOrigins.delete(window.id) + insecureOrigins.delete(window.webContents.id) + }) + if (isMac) window.webContents.session.setSpellCheckerEnabled(false) + if (process.env.NODE_ENV === "development") window.webContents.openDevTools({ mode: "detach" }) + void (backendTargetUrl ? navigateBackend(record, backendTargetUrl) : loadLoading(record)) + return record } - return join(mainDirname, "../resources/icon.png") -} - -type LoadingTarget = - | { type: "url"; source: string } - | { type: "file"; source: string } - -function resolveDevLoadingUrl(): string | null { - if (app.isPackaged) { - return null + const ensureLocalWindow = () => { + const existing = registry.mruRecord() + if (existing) return existing + const persistedId = clientState.windowIds.find((id) => !registry.get(id)) + return persistedId ? createWindow(persistedId) : undefined } - const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL - if (!devBase) { - return null + const createNewWindow = async () => { + const identity = await allocateLocalWindowIdentity( + clientState.windowIds, + (id) => Boolean(registry.get(id)), + () => clientState.addWindow(), + (error) => console.warn("[client-state] failed to persist a new window; using an ephemeral window", error), + ) + return createWindow(identity.id, identity.persisted) } - - try { - const normalized = devBase.endsWith("/") ? devBase : `${devBase}/` - return new URL("loading.html", normalized).toString() - } catch (error) { - console.warn("[cli] failed to construct dev loading URL", devBase, error) - return null + const handleIntent = async (intent: LaunchIntent) => { + const record = intent.newWindow ? await createNewWindow() : registry.focusMru() ?? ensureLocalWindow() ?? await createNewWindow() + for (const folder of intent.folders) registry.queueFolder(record.id, folder) + registry.focus(record.id) } -} + const intentQueue = createLaunchIntentQueue(handleIntent, (error) => console.error("[electron-startup] launch intent failed", error)) + const firstLaunch = intentQueue.enqueue(firstIntent) + void firstLaunch.catch(() => {}) -function resolveLoadingTarget(): LoadingTarget { - const devUrl = resolveDevLoadingUrl() - if (devUrl) { - return { type: "url", source: devUrl } + setupCliIPC(cli, { + resolveLocal: (sender) => registry.resolve(sender), getAllowedOrigins, + openRemoteWindow, newWindow: () => intentQueue.enqueue({ newWindow: true, folders: [] }), + nextFolder: (id) => registry.nextFolder(id), acknowledgeFolder: (id, folder, opened) => registry.acknowledgeFolder(id, folder, opened), + }) + lifecycle.registerAppEvents() + app.on("second-instance", (_event, argv, workingDirectory) => { + void intentQueue.enqueue(parseLaunchIntent(argvForLaunch(argv), workingDirectory || process.cwd())).catch(() => {}) + }) + app.on("open-file", (event, path) => { + event.preventDefault() + 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 + backendUrl = status.url + bootstrap.setReady(status.url) + registry.fanout("cli:ready", status) + }) + cli.on("status", (status) => { + registry.fanout("cli:status", status) + if (status.state !== "ready") { + bootstrap.reset() + backendUrl = null + backendTargetUrl = null + for (const record of registry.all()) void loadLoading(record) + } + }) + cli.on("error", (error) => registry.fanout("cli:error", { message: error.message })) + + app.whenReady().then(async () => { + try { app.setAppUserModelId("ai.neuralnomads.codenomad.client") } catch {} + if (isMac) { + session.defaultSession.setSpellCheckerEnabled(false) + configureMediaPermissionHandlers(getAllowedOrigins) + if (app.dock) { const icon = nativeImage.createFromPath(getIconPath()); if (!icon.isEmpty()) app.dock.setIcon(icon) } + } + createApplicationMenu({ + getLocalTarget: () => { + const focused = BrowserWindow.getFocusedWindow() + const mru = registry.mruRecord()?.window ?? null + return resolveFocusedLocalTarget(focused, mru, (window) => Boolean(registry.resolve(window.webContents))) + }, + getWindowTarget: () => resolveWindowTarget(BrowserWindow.getFocusedWindow(), registry.mruRecord()?.window ?? null), + newWindow: () => { void intentQueue.enqueue({ newWindow: true, folders: [] }).catch(() => {}) }, + reload: (window) => { const record = registry.resolve(window.webContents); if (record) void record.navigation.navigate((target) => target.webContents.reload()); else window.webContents.reload() }, + forceReload: (window) => { const record = registry.resolve(window.webContents); if (record) void record.navigation.navigate((target) => target.webContents.reloadIgnoringCache()); else window.webContents.reloadIgnoringCache() }, + }) + for (const id of clientState.windowIds) createWindow(id) + registry.focus(clientState.activeWindowId) + intentQueue.start() + await firstLaunch.catch(() => {}) + void cli.start({ dev: !app.isPackaged }).catch((error) => registry.fanout("cli:error", { message: error instanceof Error ? error.message : String(error) })) + app.on("activate", () => { if (registry.all().length === 0) void intentQueue.enqueue({ newWindow: false, folders: [] }).catch(() => {}) }) + }) + + function getIconPath() { + return app.isPackaged ? join(process.resourcesPath, "icon.png") : join(mainDirname, "../resources/icon.png") } - const filePath = resolveLoadingFilePath() - return { type: "file", source: filePath } -} - -function resolveLoadingFilePath() { - const candidates = [ - join(app.getAppPath(), "dist/renderer/loading.html"), - join(process.resourcesPath, "dist/renderer/loading.html"), - join(mainDirname, "../dist/renderer/loading.html"), - ] - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate + function getPreloadPath() { + 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 }) { + const base = new URL(payload.baseUrl) + const target = new URL(payload.entryUrl ?? payload.baseUrl) + const title = `${payload.name} - ${payload.baseUrl}` + const existing = remoteWindows.reuse(payload.id, payload.proxySessionId) + if (existing) { + const allowedOrigins = new Set([base.origin, target.origin]) + remoteOrigins.set(existing.id, allowedOrigins) + if (payload.skipTlsVerify) insecureOrigins.set(existing.webContents.id, allowedOrigins) + else insecureOrigins.delete(existing.webContents.id) + existing.setTitle(title) + await existing.loadURL(target.toString()) + 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 allowedOrigins = new Set([base.origin, target.origin]) + remoteOrigins.set(window.id, allowedOrigins) + if (payload.skipTlsVerify) insecureOrigins.set(window.webContents.id, allowedOrigins) + 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) + window.on("closed", () => { remoteOrigins.delete(window.id); insecureOrigins.delete(window.webContents.id) }) + try { await window.loadURL(target.toString()) } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(`

${escapeHtml(payload.name)}

${escapeHtml(message)}

`)}`) } } - - return join(app.getAppPath(), "dist/renderer/loading.html") } -async function loadLoadingScreen(window: BrowserWindow): Promise { - const target = resolveLoadingTarget() - try { - await (target.type === "url" ? window.loadURL(target.source) : window.loadFile(target.source)) - return true - } catch (error) { - if (isIgnorableNavigationError(error)) { - return false - } - console.error("[cli] failed to load loading screen:", error) - return false +function setupNavigationGuards(window: BrowserWindow, navigation: ClientStateNavigationController | undefined, allowedOrigins: (window: BrowserWindow) => string[]) { + const external = (url: string) => shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) + const shouldOpenExternally = (url: string) => { + try { const parsed = new URL(url); return !["http:", "https:", "file:"].includes(parsed.protocol) || (parsed.protocol !== "file:" && !allowedOrigins(window).includes(parsed.origin)) } catch { return false } } + window.webContents.setWindowOpenHandler(({ url }) => shouldOpenExternally(url) ? (external(url), { action: "deny" }) : { action: "allow" }) + window.webContents.on("will-navigate", (event, url) => { + if (shouldOpenExternally(url)) { event.preventDefault(); void external(url) } + else if (navigation) { event.preventDefault(); void navigation.navigate((target) => target.loadURL(url)) } + }) + window.webContents.on("will-redirect", (event, url) => { if (shouldOpenExternally(url)) { event.preventDefault(); void external(url) } }) } function isIgnorableNavigationError(error: unknown): boolean { - if (!error || typeof error !== "object") { - return false - } - - const code = "code" in error ? String((error as { code?: unknown }).code ?? "") : "" - const message = "message" in error ? String((error as { message?: unknown }).message ?? "") : "" - return code === "ERR_ABORTED" || code === "ERR_FAILED" || message.includes("ERR_ABORTED") || message.includes("ERR_FAILED") + const text = error instanceof Error ? `${(error as Error & { code?: string }).code ?? ""} ${error.message}` : String(error) + return text.includes("ERR_ABORTED") || text.includes("ERR_FAILED") } -function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] { - const origins = new Set() - if (window) { - for (const origin of remoteWindowOrigins.get(window.id) ?? []) { - origins.add(origin) - } - } - for (const origin of resolveConfiguredRendererOrigins(currentCliUrl, app.isPackaged, [ - process.env.VITE_DEV_SERVER_URL, - process.env.ELECTRON_RENDERER_URL, - ])) { - origins.add(origin) - } - return Array.from(origins) -} +function escapeHtml(value: string): string { return value.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[char]!) } -function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean { - try { - const parsed = new URL(url) - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return true - } - const allowedOrigins = getAllowedRendererOrigins(window) - return !allowedOrigins.includes(parsed.origin) - } catch { - return false - } -} - -function setupNavigationGuards(window: BrowserWindow, navigationController?: ClientStateNavigationController) { - const handleExternal = (url: string) => { - shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) - } - - window.webContents.setWindowOpenHandler(({ url }) => { - if (shouldOpenExternally(url, window)) { - handleExternal(url) - return { action: "deny" } - } - return { action: "allow" } - }) - - window.webContents.on("will-navigate", (event, url) => { - if (shouldOpenExternally(url, window)) { - event.preventDefault() - handleExternal(url) - } else if (navigationController) { - event.preventDefault() - void navigationController.navigate((target) => target.loadURL(url)).catch((error) => { - if (!isIgnorableNavigationError(error)) { - console.error("[client-state] trusted renderer navigation failed", error) - } - }) - } - }) - - window.webContents.on("will-redirect", (event, url) => { - if (shouldOpenExternally(url, window)) { - event.preventDefault() - handleExternal(url) - } - }) -} - -function setWindowAllowedOrigin(window: BrowserWindow, url: string) { - try { - const origin = new URL(url).origin - remoteWindowOrigins.set(window.id, new Set([origin])) - } catch (error) { - console.warn("[cli] failed to store allowed origin", url, error) - } -} - -function stageWindowAllowedOrigin(window: BrowserWindow, url: string): () => void { - const previous = remoteWindowOrigins.get(window.id) - try { - const origins = new Set(previous) - origins.add(new URL(url).origin) - remoteWindowOrigins.set(window.id, origins) - } catch (error) { - console.warn("[cli] failed to stage allowed origin", url, error) - } - return () => { - if (previous) remoteWindowOrigins.set(window.id, previous) - else remoteWindowOrigins.delete(window.id) - } -} - -function clearWindowAllowedOrigin(window: BrowserWindow) { - remoteWindowOrigins.delete(window.id) -} - -function addWindowInsecureOrigin(window: BrowserWindow, url: string) { - try { - const origin = new URL(url).origin - insecureWindowOrigins.set(window.id, new Set([origin])) - } catch (error) { - console.warn("[cli] failed to store insecure origin", url, error) - } -} - -function clearWindowInsecureOrigin(window: BrowserWindow) { - insecureWindowOrigins.delete(window.id) -} - -function isInsecureOriginAllowed(url: string) { - try { - const targetOrigin = new URL(url).origin - for (const origins of insecureWindowOrigins.values()) { - if (origins.has(targetOrigin)) { - return true - } - } - } catch { - return false - } - - return false -} - -let cachedPreloadPath: string | null = null -function getPreloadPath() { - if (cachedPreloadPath && existsSync(cachedPreloadPath)) { - return cachedPreloadPath - } - - const candidates = [ - join(process.resourcesPath, "preload/index.js"), - join(mainDirname, "../preload/index.js"), - join(mainDirname, "../preload/index.cjs"), - join(mainDirname, "../../preload/index.cjs"), - join(mainDirname, "../../electron/preload/index.cjs"), - join(app.getAppPath(), "preload/index.cjs"), - join(app.getAppPath(), "electron/preload/index.cjs"), - ] - - for (const candidate of candidates) { - if (existsSync(candidate)) { - cachedPreloadPath = candidate - return candidate - } - } - - return join(mainDirname, "../preload/index.js") -} - -function destroyPreloadingView(target?: BrowserView | null) { - const view = target ?? preloadingView - if (!view) { - return - } - - try { - const contents = view.webContents as any - contents?.destroy?.() - } catch (error) { - console.warn("[cli] failed to destroy preloading view", error) - } - - if (!target || view === preloadingView) { - preloadingView = null - } -} - -function createWindow() { - const prefersDark = true - const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff" - const iconPath = getIconPath() - const savedWindowState = clientStateManager.getWindowState() - const restoredBounds = savedWindowState - ? clampWindowBounds( - savedWindowState.bounds, - screen.getAllDisplays().map((display) => display.workArea), - ) - : undefined - - mainWindow = new BrowserWindow({ - width: restoredBounds?.width ?? DEFAULT_WINDOW_WIDTH, - height: restoredBounds?.height ?? DEFAULT_WINDOW_HEIGHT, - useContentSize: true, - ...(restoredBounds ? { x: restoredBounds.x, y: restoredBounds.y } : {}), - minWidth: 800, - minHeight: 600, - backgroundColor, - icon: iconPath, - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - ...(savedWindowState ? { zoomFactor: savedWindowState.zoomFactor } : {}), - spellcheck: !isMac, - additionalArguments: ["--codenomad-window-context=local"], - }, - }) - - const window = mainWindow - const navigationController = new ClientStateNavigationController( - window, - { - clientStateManager, - isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedRendererOrigins(window)), - reportFlushError: (error) => { - console.warn("[client-state] renderer pre-navigation flush failed; continuing navigation", error) - }, - }, - ) - mainNavigationController = navigationController - - let windowStateTracker: WindowStateTracker | null = null - if (clientStateManager.isPrimary) { - restoreWindowState(window, savedWindowState, restoredBounds) - windowStateTracker = new WindowStateTracker(window, clientStateManager, savedWindowState) - } - installWindowZoomInput(window, (level) => { - if (windowStateTracker) windowStateTracker.setZoomLevel(level) - else window.webContents.setZoomLevel(level) - }) - - setupNavigationGuards(window, navigationController) - - if (isMac) { - window.webContents.session.setSpellCheckerEnabled(false) - } - - showingLoadingScreen = true - currentCliUrl = null - clearWindowAllowedOrigin(window) - void loadLoadingScreen(window) - - if (process.env.NODE_ENV === "development") { - window.webContents.openDevTools({ mode: "detach" }) - } - - createApplicationMenu(window, { - reload: () => { - void navigationController.navigate((target) => target.webContents.reload()) - }, - forceReload: () => { - void navigationController.navigate((target) => target.webContents.reloadIgnoringCache()) - }, - }) - setupCliIPC(window, cliManager) - bindClientStateWindow(window) - clientStateLifecycle.attachMainWindow(window, windowStateTracker) - - window.on("closed", () => { - destroyPreloadingView() - clearWindowAllowedOrigin(window) - clearWindowInsecureOrigin(window) - mainWindow = null - if (mainNavigationController === navigationController) mainNavigationController = null - currentCliUrl = null - pendingCliUrl = null - showingLoadingScreen = false - clientStateLifecycle.detachMainWindow(window) - }) - - if (pendingCliUrl) { - const url = pendingCliUrl - pendingCliUrl = null - startCliPreload(url) - } -} - -function showLoadingScreen(force = false) { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - - if (showingLoadingScreen && !force) { - return - } - - const window = mainWindow - const wasShowingLoadingScreen = showingLoadingScreen - showingLoadingScreen = true - destroyPreloadingView() - pendingCliUrl = null - void mainNavigationController?.navigate(async (target) => { - if (!(await loadLoadingScreen(target))) { - showingLoadingScreen = wasShowingLoadingScreen - return - } - currentCliUrl = null - clearWindowAllowedOrigin(target) - }) -} - -function isBootstrapTokenUrl(url: string): boolean { - try { - const parsed = new URL(url) - return parsed.pathname === "/auth/token" && parsed.hash.length > 1 - } catch { - return false - } -} - -function startCliPreload(url: string) { - if (!mainWindow || mainWindow.isDestroyed()) { - pendingCliUrl = url - return - } - - if (currentCliUrl === url && !showingLoadingScreen) { - return - } - - pendingCliUrl = url - destroyPreloadingView() - - if (!showingLoadingScreen) { - showLoadingScreen(true) - } - - // Important: /auth/token#... is one-time. Preloading + swapping would load it twice, - // consuming the token in the hidden view and then failing in the main window. - if (isBootstrapTokenUrl(url)) { - finalizeCliSwap(url) - return - } - - const view = new BrowserView({ - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - spellcheck: !isMac, - }, - }) - - preloadingView = view - - view.webContents.once("did-finish-load", () => { - if (preloadingView !== view) { - destroyPreloadingView(view) - return - } - finalizeCliSwap(url) - }) - - view.webContents.loadURL(url).catch((error) => { - if (isIgnorableNavigationError(error)) { - return - } - console.error("[cli] failed to preload CLI view:", error) - if (preloadingView === view) { - destroyPreloadingView(view) - } - }) -} - -function finalizeCliSwap(url: string) { - destroyPreloadingView() - - if (!mainWindow || mainWindow.isDestroyed()) { - pendingCliUrl = url - return - } - - const navigate = async (target: BrowserWindow) => { - const rollbackOrigin = stageWindowAllowedOrigin(target, url) - try { - await target.loadURL(url) - } catch (error) { - rollbackOrigin() - throw error - } - showingLoadingScreen = false - currentCliUrl = url - setWindowAllowedOrigin(target, url) - pendingCliUrl = null - } - void mainNavigationController?.navigate(navigate).then(() => { - if (cliManager.getStatus().state !== "ready") showLoadingScreen() - }).catch((error) => { - if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load CLI view:", error) - }) -} - -function buildRemoteWindowTitle(name: string, baseUrl: string) { - return `${name} - ${baseUrl}` -} - -function lockWindowTitle(window: BrowserWindow, title: string) { - window.setTitle(title) - window.webContents.on("page-title-updated", (event) => { - event.preventDefault() - window.setTitle(title) - }) -} - -function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) { - const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) - const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) - const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) - return `${escapedName}

${escapedName}

Could not connect to the remote server.

${escapedMessage}

${escapedUrl}
` -} - -async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) { - const targetUrl = new URL(payload.baseUrl) - const title = buildRemoteWindowTitle(payload.name, payload.baseUrl) - const window = new BrowserWindow({ - width: 1400, - height: 900, - minWidth: 800, - minHeight: 600, - backgroundColor: "#1a1a1a", - icon: getIconPath(), - title, - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - spellcheck: !isMac, - additionalArguments: ["--codenomad-window-context=remote"], - }, - }) - lockWindowTitle(window, title) - - setWindowAllowedOrigin(window, targetUrl.toString()) - if (payload.skipTlsVerify) { - addWindowInsecureOrigin(window, targetUrl.toString()) - } - - setupNavigationGuards(window) - window.on("closed", () => { - clearWindowAllowedOrigin(window) - clearWindowInsecureOrigin(window) - }) - - try { - await window.loadURL(targetUrl.toString()) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`) - } -} - -let bootstrapExchangeInFlight = false - -function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null { - const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader - if (!raw) return null - - const first = raw.split(";")[0] ?? "" - const index = first.indexOf("=") - if (index < 0) return null - - const key = first.slice(0, index).trim() - const value = first.slice(index + 1).trim() - if (key !== name || !value) return null - - try { - return decodeURIComponent(value) - } catch { - return value - } -} - -async function exchangeBootstrapToken(baseUrl: string, token: string): Promise { - const sessionCookieName = cliManager.getAuthCookieName() +async function exchangeBootstrapToken(baseUrl: string, token: string, cli: CliProcessManager): Promise { const target = new URL("/api/auth/token", baseUrl) const body = JSON.stringify({ token }) - const transport = target.protocol === "https:" ? https : http - - const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => { - const req = transport.request( - target, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - }, - }, - (res) => { - res.resume() - resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] }) - }, - ) - - req.on("error", reject) - req.write(body) - req.end() + const result = await new Promise<{ status: number; cookie?: string }>((resolve, reject) => { + const request = transport.request(target, { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } }, (response) => { + response.resume() + resolve({ status: response.statusCode ?? 0, cookie: response.headers["set-cookie"]?.[0] }) + }) + request.on("error", reject); request.end(body) }) - - if (result.statusCode !== 200) { - return false - } - - const sessionId = extractCookieValue(result.setCookie, sessionCookieName) - if (!sessionId) { - return false - } - - await session.defaultSession.cookies.set({ - url: baseUrl, - name: sessionCookieName, - value: sessionId, - httpOnly: true, - path: "/", - sameSite: "lax", - }) - + if (result.status !== 200 || !result.cookie) return false + const first = result.cookie.split(";", 1)[0] ?? "" + const separator = first.indexOf("=") + if (first.slice(0, separator).trim() !== cli.getAuthCookieName()) return false + await session.defaultSession.cookies.set({ url: baseUrl, name: cli.getAuthCookieName(), value: decodeURIComponent(first.slice(separator + 1).trim()), httpOnly: true, path: "/", sameSite: "lax" }) return true } -async function startCli() { - try { - // In desktop dev workflows we always want the CLI to run in dev mode so it: - // - uses plain HTTP - // - proxies UI requests to the renderer dev server - // Monaco's AMD assets are served from that dev server. - const devMode = !app.isPackaged - console.info("[cli] start requested (dev mode:", devMode, ")") - await cliManager.start({ dev: devMode }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error("[cli] start failed:", message) - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("cli:error", { message }) - } - } -} - -async function maybeExchangeAndNavigate(baseUrl: string) { - if (bootstrapExchangeInFlight) { - return - } - - const token = pendingBootstrapToken - if (!token) { - startCliPreload(baseUrl) - return - } - - bootstrapExchangeInFlight = true - - try { - const ok = await exchangeBootstrapToken(baseUrl, token) - pendingBootstrapToken = null - - if (!ok) { - startCliPreload(`${baseUrl}/login`) - return - } - - startCliPreload(baseUrl) - } catch (error) { - console.error("[cli] bootstrap token exchange failed:", error) - pendingBootstrapToken = null - startCliPreload(`${baseUrl}/login`) - } finally { - bootstrapExchangeInFlight = false - } -} - -cliManager.on("bootstrapToken", (token) => { - pendingBootstrapToken = token - - const status = cliManager.getStatus() - if (status.url) { - void maybeExchangeAndNavigate(status.url) - } -}) - -cliManager.on("ready", (status) => { - if (!status.url) { - return - } - - void maybeExchangeAndNavigate(status.url) -}) - -cliManager.on("status", (status) => { - if (status.state !== "ready") { - showLoadingScreen() - } -}) - -if (isMac) { - app.on("web-contents-created", (_, contents) => { - contents.session.setSpellCheckerEnabled(false) - }) -} - -app.whenReady().then(() => { - // Required for Windows notifications / taskbar grouping. - // Keep in sync with desktop app identifier. - try { - app.setAppUserModelId("ai.neuralnomads.codenomad.client") - } catch { - // ignore - } - - startCli() - - if (isMac) { - session.defaultSession.setSpellCheckerEnabled(false) - configureMediaPermissionHandlers(getAllowedRendererOrigins) - app.on("browser-window-created", (_, window) => { - window.webContents.session.setSpellCheckerEnabled(false) - }) - - if (app.dock) { - const dockIcon = nativeImage.createFromPath(getIconPath()) - if (!dockIcon.isEmpty()) { - app.dock.setIcon(dockIcon) - } - } - } - - createWindow() - ;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow - - app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => { - if (isInsecureOriginAllowed(url)) { - event.preventDefault() - console.warn("[cli] allowing insecure remote certificate for", url, error) - callback(true) - return - } - callback(false) - }) - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow() - } - }) -}) - -clientStateLifecycle.registerAppEvents() +if (isMac) app.commandLine.appendSwitch("disable-spell-checking") diff --git a/packages/electron-app/electron/main/menu-target.test.ts b/packages/electron-app/electron/main/menu-target.test.ts new file mode 100644 index 00000000..6e0e655c --- /dev/null +++ b/packages/electron-app/electron/main/menu-target.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { NEW_WINDOW_ACCELERATOR, resolveFocusedLocalTarget, resolveWindowTarget } from "./menu-target" + +test("New Window has its dedicated shortcut and menu actions target focused then MRU local windows", () => { + const local = { kind: "local" } + const other = { kind: "local" } + const remote = { kind: "remote" } + const isLocal = (window: { kind: string }) => window.kind === "local" + assert.equal(NEW_WINDOW_ACCELERATOR, "CmdOrCtrl+Shift+N") + assert.equal(resolveFocusedLocalTarget(local, other, isLocal), local) + assert.equal(resolveFocusedLocalTarget(remote, other, isLocal), null) + assert.equal(resolveFocusedLocalTarget(remote, remote, isLocal), null) + assert.equal(resolveFocusedLocalTarget(null, other, isLocal), other) + assert.equal(resolveWindowTarget(remote, other), remote) + assert.equal(resolveWindowTarget(null, other), other) +}) diff --git a/packages/electron-app/electron/main/menu-target.ts b/packages/electron-app/electron/main/menu-target.ts new file mode 100644 index 00000000..e28c212c --- /dev/null +++ b/packages/electron-app/electron/main/menu-target.ts @@ -0,0 +1,10 @@ +export const NEW_WINDOW_ACCELERATOR = "CmdOrCtrl+Shift+N" + +export function resolveFocusedLocalTarget(focused: T | null, mru: T | null, isLocal: (value: T) => boolean): T | null { + if (focused) return isLocal(focused) ? focused : null + return mru && isLocal(mru) ? mru : null +} + +export function resolveWindowTarget(focused: T | null, mru: T | null): T | null { + return focused ?? mru +} diff --git a/packages/electron-app/electron/main/menu.ts b/packages/electron-app/electron/main/menu.ts index ae345ba4..fb13afce 100644 --- a/packages/electron-app/electron/main/menu.ts +++ b/packages/electron-app/electron/main/menu.ts @@ -1,131 +1,101 @@ -import { app, Menu, BrowserWindow, MenuItemConstructorOptions } from "electron" +import { app, BrowserWindow, Menu, type MenuItemConstructorOptions } from "electron" +import { NEW_WINDOW_ACCELERATOR } from "./menu-target" interface ApplicationMenuActions { - reload(): void - forceReload(): void + getLocalTarget(): BrowserWindow | null + getWindowTarget(): BrowserWindow | null + newWindow(): void + reload(window: BrowserWindow): void + forceReload(window: BrowserWindow): void } -let workspaceActionsRequested = false +const workspaceEnabled = new Map() let applicationMenu: Menu | null = null -let localMainWindow: BrowserWindow | null = null +let menuInstalled = false +let actions: ApplicationMenuActions | null = null + +function target(local: boolean): BrowserWindow | null { + const window = (local ? actions?.getLocalTarget() : actions?.getWindowTarget()) ?? null + return window && !window.isDestroyed() ? window : null +} function updateWorkspaceMenuState() { - const enabled = workspaceActionsRequested && BrowserWindow.getFocusedWindow() === localMainWindow + const window = target(true) + const enabled = Boolean(window && workspaceEnabled.get(window.webContents.id)) for (const id of ["open-workspace-folder", "open-workspace-terminal", "open-workspace-editor"]) { const item = applicationMenu?.getMenuItemById(id) if (item) item.enabled = enabled } } -export function setWorkspaceMenuEnabled(enabled: boolean) { - workspaceActionsRequested = enabled +export function setWorkspaceMenuEnabled(window: BrowserWindow, enabled: boolean) { + workspaceEnabled.set(window.webContents.id, enabled) updateWorkspaceMenuState() } -export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) { - localMainWindow = mainWindow +export function clearWorkspaceMenuWindow(window: BrowserWindow) { + workspaceEnabled.delete(window.webContents.id) + updateWorkspaceMenuState() +} + +export function createApplicationMenu(menuActions: ApplicationMenuActions) { + actions = menuActions + if (menuInstalled) return + menuInstalled = true const isMac = process.platform === "darwin" - const sendCommand = (id: string) => () => { - if (id.startsWith("open-workspace-") && BrowserWindow.getFocusedWindow() !== mainWindow) return - mainWindow.webContents.send("menu:action", id) + const sendCommand = (id: string) => () => target(true)?.webContents.send("menu:action", id) + const withTarget = (operation: (window: BrowserWindow) => void) => () => { + const window = target(false) + if (window) operation(window) } const template: MenuItemConstructorOptions[] = [ - ...(isMac - ? [ - { - label: "CodeNomad", - submenu: [ - { role: "about" as const }, - { type: "separator" as const }, - { role: "hide" as const }, - { role: "hideOthers" as const }, - { role: "unhide" as const }, - { type: "separator" as const }, - { role: "quit" as const }, - ], - }, - ] - : []), + ...(isMac ? [{ label: "CodeNomad", submenu: [ + { role: "about" as const }, { type: "separator" as const }, { role: "hide" as const }, + { role: "hideOthers" as const }, { role: "unhide" as const }, { type: "separator" as const }, { role: "quit" as const }, + ] }] : []), { label: "File", submenu: [ - { - label: "New Instance", - accelerator: "CmdOrCtrl+N", - click: sendCommand("new-instance"), - }, - { type: "separator" as const }, { id: "open-workspace-folder", label: "Open Project Folder", click: sendCommand("open-workspace-folder") }, { id: "open-workspace-terminal", label: "Open Terminal Here", click: sendCommand("open-workspace-terminal") }, - { - id: "open-workspace-editor", - label: "Open Project In", - submenu: [ - { label: "VS Code", click: sendCommand("open-workspace-editor-vscode") }, - { label: "Cursor", click: sendCommand("open-workspace-editor-cursor") }, - { label: "Zed", click: sendCommand("open-workspace-editor-zed") }, - { label: "VSCodium", click: sendCommand("open-workspace-editor-vscodium") }, - ], - }, - { type: "separator" as const }, - isMac ? { role: "close" as const } : { role: "quit" as const }, - ], - }, - { - label: "Edit", - submenu: [ - { role: "undo" as const }, - { role: "redo" as const }, - { type: "separator" as const }, - { role: "cut" as const }, - { role: "copy" as const }, - { role: "paste" as const }, - ...(isMac - ? [{ role: "pasteAndMatchStyle" as const }, { role: "delete" as const }, { role: "selectAll" as const }] - : [{ role: "delete" as const }, { type: "separator" as const }, { role: "selectAll" as const }]), - ], - }, - { - label: "View", - submenu: [ - { label: "Reload", accelerator: "CmdOrCtrl+R", click: actions.reload }, - { label: "Force Reload", accelerator: "CmdOrCtrl+Shift+R", click: actions.forceReload }, - { role: "toggleDevTools" as const }, - { type: "separator" as const }, - { role: "resetZoom" as const }, - { role: "zoomIn" as const }, - { role: "zoomOut" as const }, - { type: "separator" as const }, - { role: "togglefullscreen" as const }, - ], - }, - { - label: "Window", - submenu: [ - { role: "minimize" as const }, - { role: "zoom" as const }, - ...(isMac - ? [ - { type: "separator" as const }, - { role: "front" as const }, - { type: "separator" as const }, - { role: "window" as const }, - ] - : [{ role: "close" as const }]), + { id: "open-workspace-editor", label: "Open Project In", submenu: [ + { label: "VS Code", click: sendCommand("open-workspace-editor-vscode") }, + { label: "Cursor", click: sendCommand("open-workspace-editor-cursor") }, + { label: "Zed", click: sendCommand("open-workspace-editor-zed") }, + { label: "VSCodium", click: sendCommand("open-workspace-editor-vscodium") }, + ] }, + { type: "separator" }, isMac ? { role: "close" } : { role: "quit" }, ], }, + { label: "Edit", submenu: [ + { role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, + ...(isMac ? [{ role: "pasteAndMatchStyle" as const }, { role: "delete" as const }, { role: "selectAll" as const }] + : [{ role: "delete" as const }, { type: "separator" as const }, { role: "selectAll" as const }]), + ] }, + { label: "View", submenu: [ + { label: "Reload", accelerator: "CmdOrCtrl+R", click: withTarget((window) => actions?.reload(window)) }, + { label: "Force Reload", accelerator: "CmdOrCtrl+Shift+R", click: withTarget((window) => actions?.forceReload(window)) }, + { label: "Toggle Developer Tools", accelerator: isMac ? "Alt+Command+I" : "Ctrl+Shift+I", click: withTarget((window) => window.webContents.toggleDevTools()) }, + { type: "separator" }, + { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: withTarget((window) => window.webContents.setZoomLevel(0)) }, + { label: "Zoom In", accelerator: "CmdOrCtrl+Plus", click: withTarget((window) => window.webContents.setZoomLevel(window.webContents.getZoomLevel() + 0.5)) }, + { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: withTarget((window) => window.webContents.setZoomLevel(window.webContents.getZoomLevel() - 0.5)) }, + { type: "separator" }, + { label: "Toggle Full Screen", accelerator: isMac ? "Ctrl+Command+F" : "F11", click: withTarget((window) => window.setFullScreen(!window.isFullScreen())) }, + ] }, + { label: "Window", submenu: [ + { label: "New Window", accelerator: NEW_WINDOW_ACCELERATOR, click: () => actions?.newWindow() }, + { label: "New Instance", accelerator: "CmdOrCtrl+N", click: sendCommand("new-instance") }, + { type: "separator" }, + { label: "Minimize", accelerator: "CmdOrCtrl+M", click: withTarget((window) => window.minimize()) }, + ...(isMac ? [{ role: "front" as const }] : [{ label: "Close", accelerator: "CmdOrCtrl+W", click: withTarget((window) => window.close()) }]), + ] }, ] - const menu = Menu.buildFromTemplate(template) - applicationMenu = menu - Menu.setApplicationMenu(menu) + applicationMenu = Menu.buildFromTemplate(template) + Menu.setApplicationMenu(applicationMenu) updateWorkspaceMenuState() - mainWindow.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => { - if (!isMainFrame) return - workspaceActionsRequested = false - updateWorkspaceMenuState() - }) app.on("browser-window-focus", updateWorkspaceMenuState) app.on("browser-window-blur", updateWorkspaceMenuState) } diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts new file mode 100644 index 00000000..e871ad0d --- /dev/null +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { MultiwindowLifecycle, type LifecycleWindow } from "./multiwindow-lifecycle" + +const tick = () => new Promise((resolve) => setImmediate(resolve)) +function windowRecord(id: string, calls: string[]): LifecycleWindow & { events: Map } { + const events = new Map() + const window = { + on: (name: string, handler: Function) => events.set(name, handler), isDestroyed: () => false, + hide: () => calls.push(`hide:${id}`), close: () => { calls.push(`close:${id}`); events.get("close")?.({ preventDefault: () => assert.fail() }) }, + webContents: { isDestroyed: () => false, getURL: () => "http://localhost/app", executeJavaScript: async () => calls.push(`renderer:${id}`) }, + } + return { id, window: window as never, tracker: { flush: async () => calls.push(`native:${id}`) } as never, events } +} + +test("closing one local window removes only its V3 record and leaves backend running", 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}`), getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.attach(first) + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + 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 () => { + const calls: string[] = [] + const local = windowRecord("local", calls) + const remote = { isDestroyed: () => false } + 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.window, remote as never], + removeWindowState: async (id) => calls.push(`remove:${id}`), getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.attach(local) + local.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + assert.deepEqual(calls, ["prevent", "renderer:local", "native:local", "remove:local", "close:local"]) +}) + +test("global shutdown asks all renderers concurrently before aggregate persistence", async () => { + const calls: string[] = [] + const events = new Map() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const first = windowRecord("one", calls) + const second = windowRecord("two", calls) + first.window.webContents.executeJavaScript = async () => { calls.push("renderer:one"); await gate } + second.window.webContents.executeJavaScript = async () => { calls.push("renderer:two") } + const lifecycle = new MultiwindowLifecycle({ + app: { on: (name: string, handler: Function) => events.set(name, handler), quit: () => {}, exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true, flush: async () => calls.push("aggregate"), drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, getLocalWindows: () => [first, second], getAllWindows: () => [first.window, second.window], + removeWindowState: async () => {}, getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.registerAppEvents() + events.get("before-quit")?.({ preventDefault: () => {} }) + await tick() + assert.deepEqual(calls.filter((call) => call.startsWith("renderer:")), ["renderer:one", "renderer:two"]) + assert.equal(calls.includes("aggregate"), false) + release() + await tick(); await tick() + assert.ok(calls.indexOf("aggregate") > calls.indexOf("native:two")) +}) + +test("final close retains its record and shutdown stops/releases once", async () => { + const calls: string[] = [] + const events = new Map() + const first = windowRecord("one", calls) + const app = { on: (name: string, handler: Function) => events.set(name, handler), quit: () => calls.push("quit"), exit: () => calls.push("exit") } + const lifecycle = new MultiwindowLifecycle({ + app: app as never, + clientStateManager: { isPrimary: true, flush: async () => calls.push("aggregate"), drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, getLocalWindows: () => [first], getAllWindows: () => [first.window], + removeWindowState: async () => calls.push("remove"), getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.attach(first); lifecycle.registerAppEvents() + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + assert.deepEqual(calls, ["prevent", "hide:one", "quit"]) + events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) + events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) + await tick(); await tick() + assert.equal(calls.includes("remove"), false) + assert.equal(calls.filter((call) => call === "stop").length, 1) + assert.equal(calls.filter((call) => call === "release").length, 1) +}) + +test("Windows session end exits even when CLI shutdown rejects after the query was vetoed", async () => { + const calls: string[] = [] + const first = windowRecord("one", calls) + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => {}, exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => { throw new Error("CLI failed") } } as never, + getLocalWindows: () => [first], getAllWindows: () => [first.window], removeWindowState: async () => {}, + getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, isWindows: true, sessionEndCleanupTimeoutMs: 20, + }) + lifecycle.attach(first) + let vetoed = false + first.events.get("query-session-end")?.({ preventDefault: () => { vetoed = true } }) + await tick(); await tick() + assert.equal(vetoed, true) + assert.deepEqual(calls.filter((call) => call === "exit"), ["exit"]) +}) + +test("normal quit reports a failed CLI shutdown without allowing exit", async () => { + const calls: string[] = [] + const events = new Map() + const first = windowRecord("one", calls) + const lifecycle = new MultiwindowLifecycle({ + app: { on: (name: string, handler: Function) => events.set(name, handler), quit: () => {}, exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => { throw new Error("CLI failed") } } as never, + getLocalWindows: () => [first], getAllWindows: () => [first.window], removeWindowState: async () => {}, + getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.registerAppEvents() + events.get("before-quit")?.({ preventDefault: () => calls.push("prevent") }) + await tick(); await tick() + assert.equal(calls.includes("prevent"), true) + assert.equal(calls.includes("exit"), false) + assert.equal(calls.includes("release"), false) +}) diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.ts new file mode 100644 index 00000000..976def93 --- /dev/null +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.ts @@ -0,0 +1,128 @@ +import type { App, BrowserWindow } from "electron" +import type { ClientStateManager } from "./client-state" +import type { CliProcessManager } from "./process-manager" +import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" +import type { WindowStateTracker } from "./window-state" + +export interface LifecycleWindow { + id: string + persisted?: boolean + window: BrowserWindow + tracker: WindowStateTracker | null +} + +interface Dependencies { + app: App + clientStateManager: ClientStateManager + cliManager: CliProcessManager + getLocalWindows(): LifecycleWindow[] + getAllWindows(): BrowserWindow[] + removeWindowState(id: string): Promise + getAllowedRendererOrigins(window: BrowserWindow): string[] + isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean + rendererFlushTimeoutMs?: number + sessionEndCleanupTimeoutMs?: number + isWindows?: boolean +} + +export class MultiwindowLifecycle { + private shutdown: Promise | null = null + private release: Promise | null = null + private exitAllowed = false + + constructor(private readonly dependencies: Dependencies) {} + + attach(record: LifecycleWindow): void { + let approved = false + let closing = false + 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()) + if (!otherLocal && !otherWindow) { + record.window.hide() + this.dependencies.app.quit() + return + } + closing = true + void this.flushWindow(record).then(async () => { + if (record.persisted !== false) await this.run("remove closed window state", () => this.dependencies.removeWindowState(record.id)) + approved = true + record.window.close() + }).catch((error) => { + closing = false + console.warn("[client-state] local window close failed", error) + }) + }) + + if (this.dependencies.isWindows ?? process.platform === "win32") { + record.window.on("query-session-end", (event) => { + if (this.exitAllowed) return + event.preventDefault() + this.startSessionEnd() + }) + record.window.on("session-end", () => this.startSessionEnd()) + } + } + + registerAppEvents(): void { + this.dependencies.app.on("before-quit", (event) => { + if (this.exitAllowed) return + event.preventDefault() + for (const window of this.dependencies.getAllWindows()) if (!window.isDestroyed()) window.hide() + void this.startShutdown().then(() => this.exit(), (error) => console.warn("[client-state] shutdown remains pending", error)) + }) + this.dependencies.app.on("window-all-closed", () => this.dependencies.app.quit()) + } + + private startShutdown(): Promise { + if (this.shutdown) return this.shutdown + this.shutdown = (async () => { + await Promise.all(this.dependencies.getLocalWindows().map((record) => this.flushWindow(record))) + await this.run("aggregate state flush", () => this.dependencies.clientStateManager.flush()) + await this.dependencies.cliManager.shutdown() + await this.releasePrimary() + })() + return this.shutdown + } + + private startSessionEnd(): void { + if (this.exitAllowed) return + const timeoutMs = this.dependencies.sessionEndCleanupTimeoutMs ?? 5_000 + void Promise.race([ + this.startShutdown(), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]).catch((error) => { + console.warn("[client-state] OS session-end shutdown failed; exiting at the fail-open boundary", error) + }).then(() => this.exit()) + } + + private async flushWindow(record: LifecycleWindow): Promise { + await this.run("renderer window flush", async () => { + await flushRendererClientStateBeforeShutdown( + record.window, + this.dependencies.clientStateManager.isPrimary, + (url) => this.dependencies.isTrustedRendererOrigin(url, this.dependencies.getAllowedRendererOrigins(record.window)), + this.dependencies.rendererFlushTimeoutMs, + ) + }) + if (record.tracker) await this.run("native window flush", () => record.tracker!.flush()) + } + + private releasePrimary(): Promise { + this.release ??= this.run("primary release", () => this.dependencies.clientStateManager.drainAndReleasePrimary()) + return this.release + } + + private async run(name: string, operation: () => Promise): Promise { + try { await operation() } catch (error) { console.warn(`[client-state] ${name} failed; continuing`, error) } + } + + private exit(): void { + if (this.exitAllowed) return + this.exitAllowed = true + this.dependencies.app.exit(0) + } +} diff --git a/packages/electron-app/electron/main/permissions.ts b/packages/electron-app/electron/main/permissions.ts index 5e1bf4f8..4a785621 100644 --- a/packages/electron-app/electron/main/permissions.ts +++ b/packages/electron-app/electron/main/permissions.ts @@ -1,11 +1,11 @@ -import { session, systemPreferences } from "electron" +import { session, systemPreferences, type Session } from "electron" import { isAllowedRendererOrigin } from "./renderer-origin" export { isAllowedRendererOrigin } from "./renderer-origin" const isMac = process.platform === "darwin" -export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) { +export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[], targetSession: Session = session.defaultSession) { const isAudioMediaRequest = (permission: string, details?: unknown) => { if (permission !== "media") { return false @@ -15,7 +15,7 @@ export function configureMediaPermissionHandlers(getAllowedOrigins: () => string return mediaTypes.length === 0 || mediaTypes.includes("audio") } - session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => { + targetSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => { if (!isAudioMediaRequest(permission, details)) { return false } @@ -23,7 +23,7 @@ export function configureMediaPermissionHandlers(getAllowedOrigins: () => string return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()) }) - session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => { if (!isAudioMediaRequest(permission, details)) { callback(false) return diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 090de0d1..00bd7035 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -219,10 +219,12 @@ export class CliProcessManager extends EventEmitter { const stderr = child.stderr as NodeJS.ReadableStream | undefined stdout?.on("data", (data: Buffer) => { + if (this.child !== child) return this.handleStream(data.toString(), "stdout") }) stderr?.on("data", (data: Buffer) => { + if (this.child !== child) return this.handleStream(data.toString(), "stderr") }) 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 00000000..bda77c30 --- /dev/null +++ b/packages/electron-app/electron/main/remote-window-registry.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { 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"), + on: (name: string, callback: () => void) => events.set(name, callback), + } as never, + } +} + +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 and close clean exactly their corresponding sessions", () => { + const cleaned: string[] = [] + const registry = new RemoteWindowRegistry((id) => cleaned.push(id)) + const first = window() + registry.register("profile", first.value, "proxy-one") + assert.equal(registry.reuse("profile", "proxy-two"), undefined) + assert.deepEqual(first.calls, ["close"]) + 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"]) +}) 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 00000000..67026cc6 --- /dev/null +++ b/packages/electron-app/electron/main/remote-window-registry.ts @@ -0,0 +1,37 @@ +import type { BrowserWindow } from "electron" + +interface RemoteWindowRecord { + window: BrowserWindow + proxySessionId?: string +} + +export class RemoteWindowRegistry { + private readonly records = new Map() + + constructor(private readonly cleanupProxySession: (sessionId: string) => void) {} + + 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.close() + 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) + }) + } +} diff --git a/packages/electron-app/electron/main/startup.test.ts b/packages/electron-app/electron/main/startup.test.ts new file mode 100644 index 00000000..bedbe13a --- /dev/null +++ b/packages/electron-app/electron/main/startup.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict" +import { mkdirSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, 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") + assert.equal(resolveUpdateChannel(undefined, "1.0.0", false), "dev") + assert.equal(resolveUpdateChannel(undefined, "1.0.0-dev-2", true), "dev") + assert.equal(resolveUpdateChannel(undefined, "1.0.0", true), "stable") +}) + +test("stable default storage preserves paths while dev and alternate configs are scoped", () => { + const base = join(tmpdir(), "codenomad-startup-base") + const stable = resolveStorageScope({ appVersion: "1.0.0", cwd: base, baseUserDataPath: base, packaged: true }) + assert.equal(stable.userDataPath, base) + assert.equal(stable.sessionDataPath, join(base, "session-data-v2")) + assert.equal(stable.clientStateElectionDirectory, undefined) + const dev = resolveStorageScope({ appVersion: "1.0.0-dev.1", cwd: base, baseUserDataPath: base, packaged: true }) + const alternate = resolveStorageScope({ appVersion: "1.0.0", cliConfig: "other/config.json", cwd: base, baseUserDataPath: base, packaged: true }) + assert.match(dev.userDataPath, /scopes[\\/]dev-[0-9a-f]{16}$/) + assert.equal(dev.clientStateElectionDirectory, join(dev.userDataPath, "client-state", "election")) + assert.match(alternate.userDataPath, /scopes[\\/]stable-[0-9a-f]{16}$/) + assert.equal(alternate.clientStateElectionDirectory, join(alternate.userDataPath, "client-state", "election")) + 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 }) + assert.equal(additions, 0) + assert.deepEqual(await allocateLocalWindowIdentity([], () => false, async () => null, undefined, () => "ephemeral"), { id: "ephemeral", persisted: false }) + assert.deepEqual(await allocateLocalWindowIdentity([], () => false, async () => { throw new Error("frozen") }, () => {}, () => "fallback"), { id: "fallback", persisted: false }) +}) + +test("launch intents wait for readiness and remain serialized across async window creation", async () => { + const calls: string[] = [] + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { releaseFirst = resolve }) + const queue = createLaunchIntentQueue(async (intent) => { + calls.push(`start:${intent.folders[0]}`) + if (intent.folders[0] === "first") await firstGate + calls.push(`end:${intent.folders[0]}`) + }, (error) => assert.fail(String(error))) + const first = queue.enqueue({ newWindow: true, folders: ["first"] }) + const second = queue.enqueue({ newWindow: false, folders: ["second"] }) + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, []) + queue.start() + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, ["start:first"]) + releaseFirst() + await Promise.all([first, second, queue.idle()]) + assert.deepEqual(calls, ["start:first", "end:first", "start:second", "end:second"]) +}) + +test("launch arguments resolve valid folders relative to launch cwd and ignore unknown flags", () => { + const root = join(tmpdir(), `codenomad-launch-${process.pid}`) + const folder = join(root, "workspace") + mkdirSync(folder, { recursive: true }) + try { + assert.deepEqual(parseLaunchIntent(["--ignored", "--new-window", "--folder", "workspace", "missing", "workspace"], root), { + newWindow: true, + folders: [folder], + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("a losing native lock quits without primary side effects", () => { + const calls: string[] = [] + assert.equal(startPrimaryInstance(() => (calls.push("lock"), false), () => calls.push("quit"), () => calls.push("construct")), false) + assert.deepEqual(calls, ["lock", "quit"]) +}) + +test("bootstrap waits for a same-generation URL and token and discards late completion", async () => { + const calls: string[] = [] + let release!: (accepted: boolean) => void + const firstExchange = new Promise((resolve) => { release = resolve }) + let exchanges = 0 + const coordinator = new BackendBootstrapCoordinator( + async (url, token) => { calls.push(`exchange:${url}:${token}`); return exchanges++ === 0 ? firstExchange : true }, + (url) => { calls.push(`navigate:${url}`) }, + ) + coordinator.setReady("http://old") + assert.deepEqual(calls, []) + coordinator.setToken("old-token") + assert.deepEqual(calls, ["exchange:http://old:old-token"]) + coordinator.reset() + coordinator.setToken("new-token") + coordinator.setReady("http://new") + await coordinator.idle() + release(true) + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, ["exchange:http://old:old-token", "exchange:http://new:new-token", "navigate:http://new"]) +}) diff --git a/packages/electron-app/electron/main/startup.ts b/packages/electron-app/electron/main/startup.ts new file mode 100644 index 00000000..9e0c27be --- /dev/null +++ b/packages/electron-app/electron/main/startup.ts @@ -0,0 +1,219 @@ +import { createHash, randomUUID } from "node:crypto" +import { statSync } from "node:fs" +import { homedir } from "node:os" +import { isAbsolute, join, normalize, resolve } from "node:path" + +export interface LaunchIntent { + newWindow: boolean + folders: string[] +} + +export interface StorageScope { + channel: string + configIdentity: string + scoped: boolean + userDataPath: string + sessionDataPath: string + clientStateElectionDirectory?: string +} + +export interface LocalWindowIdentity { + id: string + persisted: boolean +} + +export class BackendBootstrapCoordinator { + private generation = 0 + private ready: { generation: number; url: string } | undefined + private token: { generation: number; value: string } | undefined + private inFlight: Promise | undefined + + constructor( + private readonly exchange: (url: string, token: string) => Promise, + private readonly navigate: (url: string) => void | Promise, + private readonly reportError: (error: unknown) => void = () => {}, + ) {} + + reset(): void { + this.generation++ + this.ready = undefined + this.token = undefined + this.inFlight = undefined + } + + setReady(url: string): void { + this.ready = { generation: this.generation, url } + this.start() + } + + setToken(token: string): void { + this.token = { generation: this.generation, value: token } + this.start() + } + + idle(): Promise { + return this.inFlight ?? Promise.resolve() + } + + private start(): void { + if (this.inFlight || !this.ready || !this.token || this.ready.generation !== this.token.generation) return + const generation = this.generation + const url = this.ready.url + const token = this.token.value + this.inFlight = this.exchange(url, token).then( + (accepted) => this.generation === generation ? this.navigate(accepted ? url : `${url}/login`) : undefined, + (error) => { + this.reportError(error) + return this.generation === generation ? this.navigate(`${url}/login`) : undefined + }, + ).then(() => undefined).finally(() => { + if (this.generation === generation) { + this.ready = undefined + this.token = undefined + this.inFlight = undefined + } + }) + } +} + +export function startPrimaryInstance(requestLock: () => boolean, losingLaunch: () => void, primaryLaunch: () => void): boolean { + if (!requestLock()) { + losingLaunch() + return false + } + primaryLaunch() + return true +} + +function normalizeConfigIdentity(raw: string | undefined, cwd: string): string { + let target = raw?.trim() || "~/.config/codenomad/config.json" + if (target === "~" || target.startsWith("~/") || target.startsWith("~\\")) { + target = join(homedir(), target.slice(2)) + } else if (!isAbsolute(target)) { + target = resolve(cwd, target) + } + target = normalize(target) + if (/\.json$/i.test(target)) target = join(resolve(target, ".."), "config.yaml") + if (!/\.ya?ml$/i.test(target)) target = join(target, "config.yaml") + return process.platform === "win32" ? target.toLowerCase() : target +} + +export function resolveUpdateChannel(environmentChannel: string | undefined, appVersion: string, packaged: boolean): string { + const explicit = environmentChannel?.trim().toLowerCase() + if (explicit) return explicit.replace(/[^a-z0-9._-]+/g, "-") + if (!packaged) return "dev" + return /-dev(?:\.|-)/i.test(appVersion) ? "dev" : "stable" +} + +export function resolveStorageScope(options: { + appVersion: string + environmentChannel?: string + cliConfig?: string + cwd: string + baseUserDataPath: string + packaged: boolean +}): StorageScope { + const channel = resolveUpdateChannel(options.environmentChannel, options.appVersion, options.packaged) + const configIdentity = normalizeConfigIdentity(options.cliConfig, options.cwd) + const defaultIdentity = normalizeConfigIdentity(undefined, options.cwd) + const scoped = channel !== "stable" || configIdentity !== defaultIdentity + const suffix = createHash("sha256").update(`${channel}\0${configIdentity}`).digest("hex").slice(0, 16) + const userDataPath = scoped ? join(options.baseUserDataPath, "scopes", `${channel}-${suffix}`) : options.baseUserDataPath + return { + channel, + configIdentity, + scoped, + userDataPath, + sessionDataPath: join(userDataPath, options.packaged ? "session-data-v2" : "session-data"), + ...(scoped ? { clientStateElectionDirectory: join(userDataPath, "client-state", "election") } : {}), + } +} + +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, + addWindow: () => Promise, + reportError: (error: unknown) => void = () => {}, + createId: () => string = randomUUID, +): Promise { + const retained = persistedIds.find((id) => !isRegistered(id)) + if (retained) return { id: retained, persisted: true } + try { + const id = await addWindow() + if (id) return { id, persisted: true } + } catch (error) { + reportError(error) + } + return { id: createId(), persisted: false } +} + +export function createLaunchIntentQueue( + handle: (intent: LaunchIntent) => void | Promise, + reportError: (error: unknown) => void, +) { + let start!: () => void + const ready = new Promise((resolve) => { start = resolve }) + let tail = Promise.resolve() + return { + enqueue(intent: LaunchIntent): Promise { + const operation = tail.then(() => ready).then(() => handle(intent)) + tail = operation.catch(reportError) + return operation + }, + start, + idle: () => tail, + } +} + +function existingDirectory(value: string, cwd: string): string | undefined { + const candidate = resolve(cwd, value) + try { + return statSync(candidate).isDirectory() ? candidate : undefined + } catch { + return undefined + } +} + +export function parseLaunchIntent(argv: string[], cwd: string): LaunchIntent { + const folders: string[] = [] + let newWindow = false + for (let index = 0; index < argv.length; index++) { + const value = argv[index]! + if (value === "--new-window") { + newWindow = true + continue + } + if (value === "--folder") { + const folder = argv[index + 1] + if (folder && !folder.startsWith("-")) { + index++ + const resolved = existingDirectory(folder, cwd) + if (resolved) folders.push(resolved) + } + continue + } + if (value.startsWith("--folder=")) { + const resolved = existingDirectory(value.slice("--folder=".length), cwd) + if (resolved) folders.push(resolved) + continue + } + if (value.startsWith("-")) continue + const resolved = existingDirectory(value, cwd) + if (resolved) folders.push(resolved) + } + return { newWindow, folders: [...new Set(folders)] } +} diff --git a/packages/electron-app/electron/main/window-state.test.ts b/packages/electron-app/electron/main/window-state.test.ts index fae89974..94c6a761 100644 --- a/packages/electron-app/electron/main/window-state.test.ts +++ b/packages/electron-app/electron/main/window-state.test.ts @@ -15,6 +15,16 @@ test("normalizes persisted window state", () => { ) }) +test("mixed-DPI monitor selection compares intersections in physical coordinates", () => { + assert.deepEqual(clampWindowBounds( + { x: 1000, y: 500, width: 700, height: 600 }, + [ + { x: 0, y: 0, width: 1920, height: 1080, scaleFactor: 1 }, + { x: 960, y: 0, width: 1280, height: 720, scaleFactor: 2 }, + ], + ), { x: 1000, y: 120, width: 800, height: 600 }) +}) + test("normalizes unsafe zoom factors", () => { assert.equal(normalizeZoomFactor(Number.POSITIVE_INFINITY), 1) assert.equal(normalizeZoomFactor(0.01), 0.25) diff --git a/packages/electron-app/electron/main/window-state.ts b/packages/electron-app/electron/main/window-state.ts index b595b495..13c11453 100644 --- a/packages/electron-app/electron/main/window-state.ts +++ b/packages/electron-app/electron/main/window-state.ts @@ -1,5 +1,19 @@ import type { BrowserWindow } from "electron" -import type { ClientStateManager, NativeWindowState, WindowBounds } from "./client-state" +import type { ClientStateManager } from "./client-state" + +export interface WindowBounds { + x: number + y: number + width: number + height: number +} + +export interface NativeWindowState { + bounds: WindowBounds + maximized: boolean + fullscreen: boolean + zoomFactor: number +} export const DEFAULT_WINDOW_WIDTH = 1400 export const DEFAULT_WINDOW_HEIGHT = 900 @@ -15,6 +29,7 @@ export interface DisplayWorkArea { y: number width: number height: number + scaleFactor?: number } function isFiniteNumber(value: unknown): value is number { @@ -25,13 +40,28 @@ function clamp(value: number, minimum: number, maximum: number): number { return Math.min(Math.max(value, minimum), maximum) } +function physicalBounds(bounds: WindowBounds, scaleFactor: number): WindowBounds { + return { + x: bounds.x * scaleFactor, + y: bounds.y * scaleFactor, + width: bounds.width * scaleFactor, + height: bounds.height * scaleFactor, + } +} + function intersectionArea(bounds: WindowBounds, area: DisplayWorkArea): number { + const scaleFactor = isFiniteNumber(area.scaleFactor) && area.scaleFactor > 0 ? area.scaleFactor : 1 + bounds = physicalBounds(bounds, scaleFactor) + area = physicalBounds(area, scaleFactor) const width = Math.max(0, Math.min(bounds.x + bounds.width, area.x + area.width) - Math.max(bounds.x, area.x)) const height = Math.max(0, Math.min(bounds.y + bounds.height, area.y + area.height) - Math.max(bounds.y, area.y)) return width * height } function centerDistanceSquared(bounds: WindowBounds, area: DisplayWorkArea): number { + const scaleFactor = isFiniteNumber(area.scaleFactor) && area.scaleFactor > 0 ? area.scaleFactor : 1 + bounds = physicalBounds(bounds, scaleFactor) + area = physicalBounds(area, scaleFactor) const x = bounds.x + bounds.width / 2 - (area.x + area.width / 2) const y = bounds.y + bounds.height / 2 - (area.y + area.height / 2) return x * x + y * y @@ -100,16 +130,19 @@ export function clampWindowBounds(bounds: WindowBounds, displays: DisplayWorkAre return centerDistanceSquared(normalized, area) < centerDistanceSquared(normalized, best) ? area : best }) - const maximumWidth = Math.max(1, Math.floor(display.width)) - const maximumHeight = Math.max(1, Math.floor(display.height)) - const minimumWidth = Math.min(MIN_WINDOW_WIDTH, maximumWidth) - const minimumHeight = Math.min(MIN_WINDOW_HEIGHT, maximumHeight) - const width = clamp(normalized.width, minimumWidth, maximumWidth) - const height = clamp(normalized.height, minimumHeight, maximumHeight) - const x = clamp(normalized.x, display.x, display.x + maximumWidth - width) - const y = clamp(normalized.y, display.y, display.y + maximumHeight - height) + const scaleFactor = isFiniteNumber(display.scaleFactor) && display.scaleFactor > 0 ? display.scaleFactor : 1 + const physical = physicalBounds(normalized, scaleFactor) + const physicalDisplay = physicalBounds(display, scaleFactor) + const maximumWidth = Math.max(1, Math.floor(physicalDisplay.width)) + const maximumHeight = Math.max(1, Math.floor(physicalDisplay.height)) + const minimumWidth = Math.min(MIN_WINDOW_WIDTH * scaleFactor, maximumWidth) + const minimumHeight = Math.min(MIN_WINDOW_HEIGHT * scaleFactor, maximumHeight) + const width = clamp(physical.width, minimumWidth, maximumWidth) + const height = clamp(physical.height, minimumHeight, maximumHeight) + const x = clamp(physical.x, physicalDisplay.x, physicalDisplay.x + maximumWidth - width) + const y = clamp(physical.y, physicalDisplay.y, physicalDisplay.y + maximumHeight - height) - return { x, y, width, height } + return { x: x / scaleFactor, y: y / scaleFactor, width: width / scaleFactor, height: height / scaleFactor } } export function restoreWindowState(window: BrowserWindow, state: NativeWindowState | undefined, bounds: WindowBounds | undefined) { @@ -160,6 +193,7 @@ export class WindowStateTracker { private readonly window: BrowserWindow, private readonly clientState: ClientStateManager, initialState?: NativeWindowState, + private readonly windowId = clientState.activeWindowId, ) { this.desiredZoomFactor = normalizeZoomFactor(initialState?.zoomFactor) const [x, y] = typeof window.getPosition === "function" ? window.getPosition() : [0, 0] @@ -241,7 +275,7 @@ export class WindowStateTracker { maximized: this.window.isMaximized(), fullscreen: this.window.isFullScreen(), zoomFactor: this.desiredZoomFactor, - }) + }, this.windowId) } private captureNormalBounds(): void { diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 618b5858..0f0e318a 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -7,11 +7,18 @@ function resolveWindowContext() { return context === "remote" ? "remote" : "local" } +function resolveWindowId() { + const prefix = "--codenomad-window-id=" + const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix)) + return arg ? arg.slice(prefix.length) : null +} + function resolveRuntimeHost(windowContext) { return "electron" } const windowContext = resolveWindowContext() +const windowId = resolveWindowId() const localElectronAPI = { onCliStatus: (callback) => { @@ -28,6 +35,14 @@ const localElectronAPI = { getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths), openWorkspaceTarget: (payload) => ipcRenderer.invoke("workspace:openTarget", payload), setWorkspaceMenuEnabled: (enabled) => ipcRenderer.invoke("workspace:setMenuEnabled", Boolean(enabled)), + newWindow: () => ipcRenderer.invoke("window:new"), + nextPendingFolder: () => ipcRenderer.invoke("window:nextFolder"), + acknowledgePendingFolder: (folder, opened) => ipcRenderer.invoke("window:ackFolder", folder, Boolean(opened)), + onPendingFolders: (callback) => { + const handler = () => callback() + ipcRenderer.on("window:folders-pending", handler) + return () => ipcRenderer.removeListener("window:folders-pending", handler) + }, onMenuAction: (callback) => { const handler = (_event, action) => callback(action) ipcRenderer.on("menu:action", handler) @@ -47,6 +62,8 @@ const localElectronAPI = { claimClientStateAccess: (token) => ipcRenderer.invoke("client-state:claimAccess", token), loadClientState: (token) => ipcRenderer.invoke("client-state:load", token), saveClientState: (token, snapshot) => ipcRenderer.invoke("client-state:save", token, snapshot), + commitClientStatePartitions: (token, payload) => ipcRenderer.invoke("client-state:commitPartitions", token, payload), + loadClientStatePartition: (token, key) => ipcRenderer.invoke("client-state:loadPartition", token, key), setClientStateRestoreEnabled: (token, enabled) => ipcRenderer.invoke("client-state:setRestoreEnabled", token, Boolean(enabled)), clearClientState: (token) => ipcRenderer.invoke("client-state:clear", token), @@ -63,4 +80,5 @@ contextBridge.exposeInMainWorld( windowContext === "local" ? localElectronAPI : remoteElectronAPI, ) contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext) +contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_ID__", windowContext === "local" ? windowId : null) contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext)) diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index a1b20031..7a9fcd69 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/client-state-lifecycle.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/window-state.test.ts electron/main/workspace-open.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/client-state-lifecycle.test.ts electron/main/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.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", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", diff --git a/packages/server/README.md b/packages/server/README.md index a954ccae..cd4e0d95 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -20,8 +20,9 @@ ## Prerequisites -- **OpenCode V2**: Install a compatible `opencode2` CLI. Server and UI pin the generated client together, while startup validates the independently updated CLI through service health and API behavior rather than its version string. -- **OpenCode database**: V2 uses `~/.local/share/opencode2/opencode.db`, separate from the incompatible V1 database. +- **OpenCode V2**: Install a compatible `opencode2` CLI. CodeNomad uses the CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to OpenCode's externally owned global daemon, then validates service health and API behavior rather than an exact CLI version string. +- **OpenCode data**: The global daemon owns its platform-default storage, database, and service registration. Configured startup environment applies only when CodeNomad starts a missing daemon; an existing daemon is unchanged. +- **Windows to WSL**: A configured WSL UNC binary uses Linux `service status`, `service start`, and `service get password`; Windows must have WSL localhost forwarding enabled to reach its loopback service. - Node.js 18+ and npm (for running or building from source). - A workspace folder on disk you want to serve. - Optional: a Chromium-based browser if you want `--launch` to open the UI automatically. @@ -99,7 +100,6 @@ You can configure the server using flags or environment variables: | `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates | | `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true`) | | `--ui-manifest-url ` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | -| - | `OPENCODE_DB` | Required OpenCode V2 database path. CodeNomad provides no default; do not reuse a V1 database. | ### Dev Releases (Advanced) @@ -221,11 +221,12 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo - **Mutable server state**: `~/.config/codenomad/state.yaml` - **Legacy migration input**: `~/.config/codenomad/config.json` is migrated to the YAML files above. - **CodeNomad instance data**: `~/.config/codenomad/instances/` -- **OpenCode V2 sessions and messages**: the user-selected `OPENCODE_DB`; CodeNomad does not choose a default path. -- **Shared-service coordination state**: `~/.codenomad/state/opencode-v2/` +- **OpenCode V2 sessions, messages, and service registration**: OpenCode's platform-default global locations. - **Desktop restore state**: `~/.codenomad/client-state/v2/` -Changing the OpenCode binary or its environment, including `OPENCODE_DB`, takes effect when the shared service next starts or restarts. It does not reconfigure an already-running service. +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. + +Explicit **Stop Workspace** evicts that location and its resources from the global service without stopping the daemon. Closing a UI tab or native window only detaches local state and never evicts. Backend shutdown clears only CodeNomad's in-memory connection state and never stops the global service. ### Event Delivery diff --git a/packages/server/src/clients/connection-manager.test.ts b/packages/server/src/clients/connection-manager.test.ts new file mode 100644 index 00000000..e3c227d1 --- /dev/null +++ b/packages/server/src/clients/connection-manager.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { Logger } from "../logger.ts" +import { ClientConnectionManager } from "./connection-manager" + +const logger = { debug() {}, warn() {} } as unknown as Logger + +describe("ClientConnectionManager", () => { + it("does not let an old unregister closure remove its replacement", () => { + const manager = new ClientConnectionManager(logger) + let replacementCloses = 0 + const oldUnregister = manager.register({ clientId: "client", connectionId: "window", close() {} }) + manager.register({ clientId: "client", connectionId: "window", close() { replacementCloses += 1 } }) + + oldUnregister() + assert.equal(manager.isConnected({ clientId: "client", connectionId: "window" }), true) + assert.equal(replacementCloses, 0) + manager.shutdown() + }) + + it("keeps delimiter-colliding identifier tuples independent", () => { + const manager = new ClientConnectionManager(logger) + manager.register({ clientId: "client:window", connectionId: "one", close() {} }) + manager.register({ clientId: "client", connectionId: "window:one", close() {} }) + + assert.equal(manager.isConnected({ clientId: "client:window", connectionId: "one" }), true) + assert.equal(manager.isConnected({ clientId: "client", connectionId: "window:one" }), true) + manager.shutdown() + }) +}) diff --git a/packages/server/src/clients/connection-manager.ts b/packages/server/src/clients/connection-manager.ts index 7eaa426f..4aca1861 100644 --- a/packages/server/src/clients/connection-manager.ts +++ b/packages/server/src/clients/connection-manager.ts @@ -67,7 +67,7 @@ export class ClientConnectionManager { this.connections.set(key, connection) this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected") this.notify({ type: "connected", connection }) - return () => this.disconnect(key, "closed") + return () => this.disconnect(key, "closed", true, connection) } pong(input: ClientConnectionRef): boolean { @@ -95,9 +95,9 @@ export class ClientConnectionManager { } } - private disconnect(key: string, reason: string, invokeClose = true): void { + private disconnect(key: string, reason: string, invokeClose = true, expected?: RegisteredConnection): void { const connection = this.connections.get(key) - if (!connection) return + if (!connection || (expected && connection !== expected)) return this.connections.delete(key) this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected") @@ -124,5 +124,5 @@ export class ClientConnectionManager { } function getConnectionKey(input: ClientConnectionRef): string { - return `${input.clientId}:${input.connectionId}` + return JSON.stringify([input.clientId, input.connectionId]) } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7ae85fae..a0f81288 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -360,8 +360,6 @@ async function main() { logger: logger.child({ component: "tls" }), }) - const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined - const settings = new SettingsService(configLocation, eventBus, configLogger) const binaryResolver = new BinaryResolver(settings) const workspaceManager = new WorkspaceManager({ @@ -370,7 +368,6 @@ async function main() { binaryResolver, eventBus, logger: workspaceLogger, - nodeExtraCaCertsPath, }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 565e0c53..3fa711a5 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyReply } from "fastify" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" -import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status" +import { getWorktreeGitDiff, getWorktreeGitStatus, invalidateWorktreeGitStatus } from "../../workspaces/git-status" import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations" import { cloneGitRepository, isGitCloneError } from "../../workspaces/git-clone" import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees" @@ -243,6 +243,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { if (!directory) return await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + await invalidateWorktreeGitStatus(directory) return { ok: true as const } } catch (error) { return handleWorkspaceError(error, reply) @@ -259,6 +260,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { if (!directory) return await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + await invalidateWorktreeGitStatus(directory) return { ok: true as const } } catch (error) { return handleWorkspaceError(error, reply) @@ -275,6 +277,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { if (!directory) return const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message }) + await invalidateWorktreeGitStatus(directory) return { ok: true as const, ...result } } catch (error) { return handleWorkspaceError(error, reply) diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index aad43d3c..bdb17415 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -142,58 +142,6 @@ describe("buildWindowsSpawnSpec", () => { assert.equal(spec.cwd, undefined) }) - it("propagates inherited known path variables even when they are not explicitly requested", () => { - const spec = buildWindowsSpawnSpec( - String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, - ["serve"], - { - env: { - NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`, - OPENCODE_DB: String.raw`C:\state\opencode.db`, - }, - }, - ) - - assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p:OPENCODE_DB/p") - }) - - it("preserves a Linux-native OPENCODE_DB path in WSL", () => { - const spec = buildWindowsSpawnSpec( - String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, - ["serve"], - { env: { OPENCODE_DB: "/home/dev/.local/share/opencode.db", WSLENV: "OPENCODE_DB/lp" } }, - ) - - assert.equal(spec.env?.OPENCODE_DB, "/home/dev/.local/share/opencode.db") - assert.equal(spec.env?.WSLENV, "OPENCODE_DB/l") - }) - - it("marks Windows and UNC OPENCODE_DB paths for WSL translation", () => { - for (const database of [String.raw`C:\state\opencode.db`, String.raw`\\server\state\opencode.db`]) { - const spec = buildWindowsSpawnSpec( - String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, - ["serve"], - { env: { OPENCODE_DB: database } }, - ) - - assert.equal(spec.env?.OPENCODE_DB, database) - assert.equal(spec.env?.WSLENV, "OPENCODE_DB/p") - } - }) - - it("propagates requested configured variables into WSL", () => { - const spec = buildWindowsSpawnSpec( - String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, - ["serve"], - { - env: { CUSTOM_SERVICE_VALUE: "configured" }, - propagateEnvKeys: ["CUSTOM_SERVICE_VALUE"], - }, - ) - - assert.equal(spec.env?.WSLENV, "CUSTOM_SERVICE_VALUE") - }) - it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => { const spec = buildWindowsSpawnSpec( String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, @@ -295,104 +243,29 @@ describe("resolveWslServiceDirectory", () => { }) describe("buildServiceLaunchSpec", () => { - it("returns direct commands for executables, PowerShell, and WSL", () => { + it("returns the configured host binary or discriminated WSL binary", () => { assert.deepEqual( - buildServiceLaunchSpec("opencode.exe", ["serve"], { platform: "win32" }).command, - ["opencode.exe", "serve"], + buildServiceLaunchSpec("opencode.exe", { platform: "win32" }), + { kind: "host", binary: "opencode.exe", platform: "win32" }, ) assert.deepEqual( - buildServiceLaunchSpec("opencode.ps1", ["serve"], { platform: "win32" }).command, - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "opencode.ps1", "serve"], + buildServiceLaunchSpec("opencode.ps1", { platform: "win32" }), + { kind: "host", binary: "opencode.ps1", platform: "win32" }, ) - assert.equal( - buildServiceLaunchSpec(String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, ["serve"], { platform: "win32" }).command[0], - "wsl.exe", + assert.deepEqual( + buildServiceLaunchSpec(String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, { platform: "win32" }), + { kind: "wsl", distro: "Ubuntu", binary: "/home/dev/opencode" }, ) }) - it("passes through a non-npm cmd wrapper with its required verbatim arguments", () => { - const launch = buildServiceLaunchSpec(String.raw`C:\Program Files\OpenCode\opencode.cmd`, ["serve", "--service"], { - platform: "win32", - env: { ComSpec: "test-cmd.exe" }, - }) - - assert.equal(launch.command[0], "test-cmd.exe") - assert.deepEqual(launch.command.slice(1), [ - "/d", "/s", "/c", String.raw`""C:\Program Files\OpenCode\opencode.cmd" serve --service"`, - ]) - assert.equal(launch.windowsVerbatimArguments, true) - assert.equal(launch.nativePid, false) - assert.equal(buildServiceLaunchSpec("custom.bat", ["serve"], { platform: "win32" }).nativePid, false) - assert.equal(buildServiceLaunchSpec("custom.ps1", ["serve"], { platform: "win32" }).nativePid, false) - }) - - it("launches a direct service executable without a wrapper", () => { - const launch = buildServiceLaunchSpec("opencode.exe", ["serve", "--service"], { - platform: "win32", - contenderFile: String.raw`C:\Temp\codenomad-contenders.txt`, - }) - - assert.equal(launch.command[0], process.execPath) - assert.equal(launch.command[3], "opencode.exe") - assert.deepEqual(JSON.parse(launch.command[4] ?? "[]"), ["serve", "--service"]) - assert.equal(launch.command[5], String.raw`C:\Temp\codenomad-contenders.txt`) - assert.equal(launch.nativePid, true) - assert.equal(launch.launcherRecordsPid, true) - }) - - it("translates shared Windows state and contender files for WSL", () => { - const contenderFile = String.raw`C:\Temp\codenomad\contenders.txt` + it("never converts a WSL service binary into a host lifecycle", () => { const launch = buildServiceLaunchSpec( String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, - ["serve", "--service"], - { - platform: "win32", - contenderFile, - env: { XDG_STATE_HOME: String.raw`C:\Temp\codenomad` }, - }, + { platform: "win32" }, ) - assert.equal(launch.command[0], "wsl.exe") - const wslArgs = launch.command.slice(1) - assert.match(wslArgs[5] ?? "", /wslpath -au/) - assert.equal(wslArgs[7], contenderFile) - assert.equal(launch.env?.WSLENV, "XDG_STATE_HOME/p") - assert.equal(launch.nativePid, false) - assert.equal(launch.wslDistro, "Ubuntu") - }) - - it("passes configured service variables only to the launched child", () => { - const launch = buildServiceLaunchSpec("opencode", ["serve", "--service"], { - platform: "linux", - env: { ...process.env, XDG_STATE_HOME: "/private/state", SERVICE_ONLY: "yes" }, - propagateEnvKeys: ["XDG_STATE_HOME", "SERVICE_ONLY"], - }) - - assert.deepEqual(launch.command, ["opencode", "serve", "--service"]) - assert.equal(launch.env?.XDG_STATE_HOME, "/private/state") - assert.equal(launch.env?.SERVICE_ONLY, "yes") - }) - - it("resolves the standard Windows npm opencode2 shim to its packaged executable", { skip: process.platform !== "win32" }, () => { - const root = mkdtempSync(path.join(tmpdir(), "codenomad-npm-shim-")) - const executable = path.join(root, "node_modules", "@opencode-ai", "cli", "bin", "opencode2.exe") - mkdirSync(path.dirname(executable), { recursive: true }) - writeFileSync(executable, "") - writeFileSync(path.join(root, "opencode2.cmd"), '@ECHO off\r\n"%~dp0\\node_modules\\@opencode-ai\\cli\\bin\\opencode2.exe" %*\r\n') - try { - const launch = buildServiceLaunchSpec("opencode2", ["serve", "--service"], { - platform: "win32", - cwd: root, - env: { PATH: root, PATHEXT: ".CMD" }, - contenderFile: path.join(root, "contenders.txt"), - }) - assert.equal(launch.command[0], process.execPath) - assert.equal(launch.command[3]?.toLowerCase(), executable.toLowerCase()) - assert.equal(launch.nativePid, true) - assert.equal(launch.launcherRecordsPid, true) - } finally { - rmSync(root, { recursive: true, force: true }) - } + assert.deepEqual(launch, { kind: "wsl", distro: "Ubuntu", binary: "/home/dev/opencode" }) + assert.equal("binary" in launch, true) }) }) diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index 0d5b52e8..074c06a4 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -41,8 +41,9 @@ function createManager(rootDir: string) { directory, project: { id: directory, directory, canonical: directory }, }), + evictLocation: async () => undefined, subscribe: async () => ({ async *[Symbol.asyncIterator]() {} }), - evict: async () => undefined, + shutdown: async () => undefined, } const manager = new WorkspaceManager({ rootDir, diff --git a/packages/server/src/workspaces/git-status.test.ts b/packages/server/src/workspaces/git-status.test.ts new file mode 100644 index 00000000..1d45504b --- /dev/null +++ b/packages/server/src/workspaces/git-status.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, it } from "node:test" + +import { getWorktreeGitStatus, invalidateWorktreeGitStatus } from "./git-status" + +describe("worktree git status singleflight", () => { + it("coalesces concurrent requests and runs again after settlement", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "codenomad-git-status-")) + let calls = 0 + let release!: () => void + const blocked = new Promise((resolve) => { release = resolve }) + const run = async () => { + calls += 1 + await blocked + return { ok: true as const, stdout: "" } + } + + try { + const first = getWorktreeGitStatus({ workspaceFolder: directory }, run) + const second = getWorktreeGitStatus({ workspaceFolder: path.join(directory, ".") }, run) + const deadline = Date.now() + 1_000 + while (calls === 0 && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)) + } + assert.equal(calls, 5) + release() + assert.deepEqual(await Promise.all([first, second]), [[], []]) + assert.equal(calls, 5) + + await getWorktreeGitStatus({ workspaceFolder: directory }, run) + assert.equal(calls, 10) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it("starts a new generation after a successful mutation while an older read is blocked", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "codenomad-git-status-")) + let calls = 0 + let releaseOld!: () => void + const oldBlocked = new Promise((resolve) => { releaseOld = resolve }) + const run = async () => { + calls += 1 + if (calls <= 5) await oldBlocked + return { ok: true as const, stdout: "" } + } + + try { + const oldRead = getWorktreeGitStatus({ workspaceFolder: directory }, run) + while (calls < 5) await new Promise((resolve) => setImmediate(resolve)) + await invalidateWorktreeGitStatus(path.join(directory, ".")) + const newRead = getWorktreeGitStatus({ workspaceFolder: directory }, run) + assert.deepEqual(await newRead, []) + assert.equal(calls, 10) + releaseOld() + assert.deepEqual(await oldRead, []) + + await getWorktreeGitStatus({ workspaceFolder: directory }, run) + assert.equal(calls, 15) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it("cleans up a rejected flight so the next read can retry", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "codenomad-git-status-")) + let fail = true + let calls = 0 + const run = async () => { + calls += 1 + return fail + ? { ok: false as const, error: new Error("status failed") } + : { ok: true as const, stdout: "" } + } + + try { + await assert.rejects(getWorktreeGitStatus({ workspaceFolder: directory }, run), /status failed/) + fail = false + assert.deepEqual(await getWorktreeGitStatus({ workspaceFolder: directory }, run), []) + assert.equal(calls, 10) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/server/src/workspaces/git-status.ts b/packages/server/src/workspaces/git-status.ts index 57d7cfb2..d324c715 100644 --- a/packages/server/src/workspaces/git-status.ts +++ b/packages/server/src/workspaces/git-status.ts @@ -1,5 +1,5 @@ import { spawn } from "child_process" -import { readFile } from "fs/promises" +import { readFile, realpath } from "fs/promises" import path from "path" import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types" @@ -8,6 +8,9 @@ import { normalizeGitWorktreeRelativePath } from "./git-mutations" type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } type GitSuccessResult = Extract +type RunGit = typeof runGit +const gitStatusGenerations = new Map() +const gitStatusRequests = new Map }>() async function readFileAsDiffText(filePath: string): Promise { return readFile(filePath, "utf-8") @@ -204,14 +207,36 @@ function applyNumstatOutput( export async function getWorktreeGitStatus(params: { workspaceFolder: string logger?: LogLike -}): Promise { +}, run: RunGit = runGit): Promise { + const workspaceFolder = await realpath(params.workspaceFolder) + const generation = gitStatusGenerations.get(workspaceFolder) ?? 0 + const pending = gitStatusRequests.get(workspaceFolder) + if (pending?.generation === generation) return pending.request + + const request = readWorktreeGitStatus({ ...params, workspaceFolder }, run).finally(() => { + if (gitStatusRequests.get(workspaceFolder)?.request === request) gitStatusRequests.delete(workspaceFolder) + if (!gitStatusRequests.has(workspaceFolder)) gitStatusGenerations.delete(workspaceFolder) + }) + gitStatusRequests.set(workspaceFolder, { generation, request }) + return request +} + +export async function invalidateWorktreeGitStatus(workspaceFolder: string): Promise { + const canonicalDirectory = await realpath(workspaceFolder) + gitStatusGenerations.set(canonicalDirectory, (gitStatusGenerations.get(canonicalDirectory) ?? 0) + 1) +} + +async function readWorktreeGitStatus(params: { + workspaceFolder: string + logger?: LogLike +}, run: RunGit): Promise { const { workspaceFolder, logger } = params const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([ - runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), - runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder), - runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder), - runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), - runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder), + run(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), + run(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder), + run(["ls-files", "--others", "--exclude-standard"], workspaceFolder), + run(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), + run(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder), ]) for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) { diff --git a/packages/server/src/workspaces/host-opencode-service.test.ts b/packages/server/src/workspaces/host-opencode-service.test.ts new file mode 100644 index 00000000..d7053133 --- /dev/null +++ b/packages/server/src/workspaces/host-opencode-service.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { HostOpenCodeService, hostOpenCodeServiceIdentity } from "./host-opencode-service" +import type { OpenCodeCliServiceDependencies, ServiceExecOptions } from "./opencode-cli-service" + +const url = "http://127.0.0.1:4321" + +describe("HostOpenCodeService", () => { + it("uses status, start, and password through buildSpawnSpec without a shell", async () => { + const calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }> = [] + const service = createService(calls, { PROVIDER_TOKEN: "secret", NODE_EXTRA_CA_CERTS: "/ca.pem" }) + + assert.equal(await service.discover(), undefined) + assert.deepEqual(await service.ensure(), { + url, + auth: { type: "basic", username: "opencode", password: "password" }, + }) + + assert.deepEqual(calls.map(({ args }) => args), [ + ["service", "status"], + ["service", "start"], + ["service", "get", "password"], + ]) + assert.equal(calls.every(({ options }) => options.shell === false), true) + assert.equal(calls[0]?.options.env, undefined) + assert.equal(calls[2]?.options.env, undefined) + assert.equal(calls[1]?.options.env?.PROVIDER_TOKEN, "secret") + assert.equal(calls[1]?.options.env?.NODE_EXTRA_CA_CERTS, "/ca.pem") + }) + + it("authenticates strict bounded health and rejects malformed output", async () => { + let authorization: string | null = null + const calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }> = [] + const service = createService(calls, {}, { + fetch: async (_input, init) => { + authorization = new Headers(init?.headers).get("authorization") + return Response.json({ healthy: true, version: "2.0.0", pid: 1 }) + }, + execFile: async (file, args, options) => { + calls.push({ file, args, options }) + return { stdout: args.at(-1) === "password" ? "password\n" : `${url}\n`, stderr: "" } + }, + }) + await service.discover() + assert.equal(authorization, `Basic ${Buffer.from("opencode:password").toString("base64")}`) + + const malformed = createService([], {}, { + execFile: async () => ({ stdout: `${url}\nhttp://127.0.0.1:4322\n`, stderr: "" }), + }) + await assert.rejects(malformed.discover(), /multiline/) + }) + + it("redacts startup environment values from failures and hashes identity", async () => { + const secret = "DO_NOT_LEAK" + const service = createService([], { TOKEN: secret }, { + execFile: async (_file, args) => { + if (args.at(-1) === "status") return { stdout: "stopped\n", stderr: "" } + throw Object.assign(new Error(secret), { code: 7, stdout: secret, stderr: secret }) + }, + }) + await service.discover() + await assert.rejects(service.ensure(), (error: Error) => { + assert.match(error.message, /start failed \(exit code 7\)/) + assert.equal(error.message.includes(secret), false) + return true + }) + + const identity = hostOpenCodeServiceIdentity({ + binary: process.platform === "win32" ? String.raw`C:\tools\..\opencode.exe` : "/opt/../opencode", + startupEnvironment: { TOKEN: secret }, + }) + assert.match(identity, /:env:[a-f0-9]{64}$/) + assert.equal(identity.includes(secret), false) + }) +}) + +function createService( + calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }>, + startupEnvironment: NodeJS.ProcessEnv, + overrides: Partial = {}, +) { + return new HostOpenCodeService({ + binary: process.execPath, + startupEnvironment, + timeoutMs: 500, + }, { + execFile: async (file, args, options) => { + calls.push({ file, args, options }) + const command = args.join(" ") + if (command === "service status") return { stdout: "stopped\n", stderr: "" } + if (command === "service start") return { stdout: `${url}\n`, stderr: "" } + return { stdout: "password\n", stderr: "" } + }, + fetch: async () => Response.json({ healthy: true, version: "2.0.0", pid: 123 }), + ...overrides, + }) +} diff --git a/packages/server/src/workspaces/host-opencode-service.ts b/packages/server/src/workspaces/host-opencode-service.ts new file mode 100644 index 00000000..1ce1235e --- /dev/null +++ b/packages/server/src/workspaces/host-opencode-service.ts @@ -0,0 +1,58 @@ +import { createHash } from "node:crypto" +import path from "node:path" + +import { OpenCodeCliService, type OpenCodeCliServiceDependencies } from "./opencode-cli-service" +import { buildSpawnSpec } from "./spawn" + +const DEFAULT_TIMEOUT_MS = 30_000 + +export interface HostOpenCodeServiceOptions { + binary: string + platform?: NodeJS.Platform + startupEnvironment?: NodeJS.ProcessEnv + timeoutMs?: number +} + +export class HostOpenCodeService extends OpenCodeCliService { + constructor( + options: HostOpenCodeServiceOptions, + dependencies: Partial = {}, + ) { + const platform = options.platform ?? process.platform + const startupEnvironment = daemonProcessEnvironment(options.startupEnvironment) + super({ + label: "Host", + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + command: (args, start) => buildSpawnSpec(options.binary, args, { + platform, + ...(start ? { env: startupEnvironment } : {}), + }), + }, dependencies) + } +} + +export function daemonProcessEnvironment(overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const environment = { ...process.env, ...overrides } + for (const key of Object.keys(environment)) { + if (["OPENCODE_DB", "XDG_STATE_HOME"].includes(key.toUpperCase())) delete environment[key] + } + return environment +} + +export function hostOpenCodeServiceIdentity(options: HostOpenCodeServiceOptions): string { + const platform = options.platform ?? process.platform + const binary = platform === "win32" + ? path.win32.normalize(options.binary).toLowerCase() + : path.normalize(options.binary) + return `host:${platform}:${binary}:env:${startupEnvironmentHash(options.startupEnvironment, platform)}` +} + +export function startupEnvironmentHash( + startupEnvironment: NodeJS.ProcessEnv | undefined, + platform: NodeJS.Platform = process.platform, +): string { + const environment = Object.entries(startupEnvironment ?? {}) + .map(([key, value]) => [platform === "win32" ? key.toUpperCase() : key, value ?? ""] as const) + .sort(([left], [right]) => left.localeCompare(right)) + return createHash("sha256").update(JSON.stringify(environment)).digest("hex") +} diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 875c75eb..502f5f01 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -7,13 +7,8 @@ import { EventBus } from "../events/bus" import { WorkspaceLaunchCancelledError, WorkspaceManager, - WorkspaceShutdownError, } from "./manager" -import type { OpenCodeEnsureOptions } from "./opencode-service" -import path from "node:path" -import os from "node:os" -import { execFileSync } from "node:child_process" -import { mkdtemp, rm, writeFile } from "node:fs/promises" +import type { OpenCodeServiceLifecycle, OpenCodeSharedServiceOptions } from "./opencode-service" function deferred() { let resolve!: (value: T) => void @@ -28,9 +23,10 @@ function deferred() { class ControlledSharedService { readonly validationStarted = deferred() validationGate?: ReturnType> - validationCalls: Array<{ location: LocationRef; options?: OpenCodeEnsureOptions }> = [] - evictions: LocationRef[] = [] - failEvictions = 0 + afterValidation?: () => void + validationCalls: Array<{ location: LocationRef; options?: OpenCodeSharedServiceOptions }> = [] + shutdownCalls = 0 + evictionCalls: Array<{ location: LocationRef; options?: OpenCodeSharedServiceOptions; signal?: AbortSignal }> = [] async endpoint() { return { url: "http://127.0.0.1:4321", auth: { type: "basic" as const, username: "user", password: "pass" } } @@ -44,7 +40,7 @@ class ControlledSharedService { return { authorization: "Basic token" } } - async validateLocation(location: LocationRef, requestOptions?: { signal?: AbortSignal }, options?: OpenCodeEnsureOptions) { + async validateLocation(location: LocationRef, requestOptions?: { signal?: AbortSignal }, options?: OpenCodeSharedServiceOptions) { this.validationCalls.push({ location, options }) this.validationStarted.resolve() if (this.validationGate) { @@ -57,6 +53,7 @@ class ControlledSharedService { }), ]) } + this.afterValidation?.() return { directory: location.directory, workspaceID: location.workspaceID ?? "location-1", @@ -64,16 +61,15 @@ class ControlledSharedService { } } + async evictLocation(location: LocationRef, requestOptions?: { signal?: AbortSignal }, options?: OpenCodeSharedServiceOptions) { + this.evictionCalls.push({ location, options, signal: requestOptions?.signal }) + } + async subscribe(): Promise> { return { async *[Symbol.asyncIterator]() {} } } - async evict(location: LocationRef) { - if (this.failEvictions-- > 0) throw new Error("controlled eviction failure") - this.evictions.push(location) - } - - async shutdown() {} + async shutdown() { this.shutdownCalls += 1 } } function createHarness(service = new ControlledSharedService(), overrides: Record = {}) { @@ -82,7 +78,7 @@ function createHarness(service = new ControlledSharedService(), overrides: Recor eventBus.on("workspace.stopped", (event) => stopped.push(event.workspaceId)) const manager = new WorkspaceManager({ rootDir: process.cwd(), - settings: { getOwner: () => ({ environmentVariables: { OPENCODE_DB: path.join(os.tmpdir(), "user-opencode.db") } }) } as never, + settings: { getOwner: () => ({ environmentVariables: {} }) } as never, binaryResolver: { resolveDefault: () => ({ path: process.execPath, label: "OpenCode V2" }) } as never, eventBus, logger: pino({ level: "silent" }), @@ -93,55 +89,112 @@ function createHarness(service = new ControlledSharedService(), overrides: Recor } describe("workspace manager shared service lifecycle", () => { - it("starts the selected CLI without constraining it to the client package version", async () => { + it("pins a bounded host CLI lifecycle with binary, platform, and startup environment identity", async () => { const service = new ControlledSharedService() - const { manager } = createHarness(service) + let factoryCall: unknown[] | undefined + const { manager } = createHarness(service, { + settings: { getOwner: () => ({ environmentVariables: { PROVIDER_TOKEN: "secret" } }) }, + hostServiceLifecycleFactory: (spec: unknown, timeoutMs: unknown, environment: unknown) => { + factoryCall = [spec, timeoutMs, environment] + return { discover: async () => undefined, ensure: async () => ({ url: "http://127.0.0.1:4321" }) } + }, + }) await manager.create(process.cwd()) assert.equal(service.validationCalls.length, 1) - assert.equal(service.validationCalls[0]?.options?.version, undefined) + const options = service.validationCalls[0]?.options + assert.equal(options?.kind, "lifecycle") + assert.match(options?.identity ?? "", /^host:/) + assert.equal(options?.identity.includes("secret"), false) + assert.deepEqual(factoryCall?.[0], { kind: "host", binary: process.execPath, platform: process.platform }) + assert.equal(typeof factoryCall?.[1], "number") + assert.deepEqual(factoryCall?.[2], { PROVIDER_TOKEN: "secret" }) }) - it("uses bounded WSL mappings for root and real git worktree ownership", async () => { - const base = await mkdtemp(path.join(os.tmpdir(), "codenomad-wsl-ownership-")) - const repo = path.join(base, "repo") - const worktree = path.join(base, "feature") - execFileSync("git", ["init", repo], { stdio: "ignore", timeout: 5_000 }) - await writeFile(path.join(repo, "tracked.txt"), "tracked") - execFileSync("git", ["-C", repo, "add", "."], { stdio: "ignore", timeout: 5_000 }) - execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "-m", "initial"], { - stdio: "ignore", - timeout: 5_000, - }) - execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", worktree], { stdio: "ignore", timeout: 5_000 }) + it("omits legacy storage ownership variables, keeps the current CA, and warns once", async () => { const service = new ControlledSharedService() - const servicePaths = new Map([[repo, "/service/repo"], [worktree, "/service/feature"]]) - const hostPaths = new Map(Array.from(servicePaths, ([host, servicePath]) => [servicePath, host])) + const environments: NodeJS.ProcessEnv[] = [] + const warnings: unknown[][] = [] + const previousCa = process.env.NODE_EXTRA_CA_CERTS + process.env.NODE_EXTRA_CA_CERTS = "/current/ca.pem" + const logger = { + info() {}, debug() {}, error() {}, + warn(...args: unknown[]) { warnings.push(args) }, + } + try { + const { manager } = createHarness(service, { + logger, + settings: { getOwner: () => ({ + environmentVariables: { + KEEP_ME: "yes", + NODE_EXTRA_CA_CERTS: "/configured/ca.pem", + OPENCODE_DB: "/legacy/db", + XDG_STATE_HOME: "/legacy/state", + }, + }) }, + hostServiceLifecycleFactory: (_spec: unknown, _timeoutMs: unknown, environment: NodeJS.ProcessEnv) => { + environments.push(environment) + return { discover: async () => undefined, ensure: async () => ({ url: "http://127.0.0.1:4321" }) } + }, + }) + const first = await manager.create(process.cwd()) + await manager.delete(first.workspace.id) + await manager.create(process.cwd()) + + assert.deepEqual(environments, [ + { KEEP_ME: "yes", NODE_EXTRA_CA_CERTS: "/current/ca.pem" }, + { KEEP_ME: "yes", NODE_EXTRA_CA_CERTS: "/current/ca.pem" }, + ]) + assert.equal(warnings.length, 1) + assert.deepEqual((warnings[0]?.[0] as { variables?: string[] }).variables, ["OPENCODE_DB", "XDG_STATE_HOME"]) + } finally { + if (previousCa === undefined) delete process.env.NODE_EXTRA_CA_CERTS + else process.env.NODE_EXTRA_CA_CERTS = previousCa + } + }) + + it("selects the WSL lifecycle and preserves bounded workspace path translation", async () => { + const service = new ControlledSharedService() + const lifecycle: OpenCodeServiceLifecycle = { + discover: async () => undefined, + ensure: async () => ({ url: "http://127.0.0.1:4321" }), + } + let factoryCall: unknown[] | undefined + let translationCall: unknown[] | undefined const { manager } = createHarness(service, { - rootDir: base, platform: "win32", binaryResolver: { resolveDefault: () => ({ path: String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, label: "OpenCode V2" }), }, - wslServiceDirectoryResolver: (directory: string, _distro: string, timeoutMs: number) => { - assert.ok(timeoutMs > 0 && timeoutMs <= 30_000) - return servicePaths.get(directory) ?? null + wslServiceLifecycleFactory: (spec: unknown, timeoutMs: unknown, environment: unknown) => { + factoryCall = [spec, timeoutMs, environment] + return lifecycle }, - wslHostDirectoryResolver: (directory: string, _distro: string, timeoutMs: number) => { - assert.ok(timeoutMs > 0 && timeoutMs <= 30_000) - return hostPaths.get(directory) ?? null + wslServiceDirectoryResolver: (directory: unknown, distro: unknown, timeoutMs: unknown) => { + translationCall = [directory, distro, timeoutMs] + return "/mnt/d/workspace" }, }) - try { - const { workspace } = await manager.create(repo) - assert.equal(manager.getServiceDirectory(workspace.id), "/service/repo") - assert.equal(await manager.ownsDirectory(workspace.id, "/service/repo"), true) - assert.equal(await manager.ownsDirectory(workspace.id, "/service/feature"), true) - assert.equal(await manager.ownsDirectory(workspace.id, "/service/foreign"), false) - assert.equal(await manager.getServiceDirectoryForPath(workspace.id, worktree), "/service/feature") - } finally { - await manager.shutdown().catch(() => undefined) - await rm(base, { recursive: true, force: true }) - } + + await manager.create(process.cwd()) + + assert.deepEqual(factoryCall?.[0], { kind: "wsl", distro: "Ubuntu", binary: "/home/dev/opencode" }) + assert.equal(typeof factoryCall?.[1], "number") + assert.deepEqual(translationCall?.slice(0, 2), [process.cwd(), "Ubuntu"]) + assert.ok(Number(translationCall?.[2]) > 0) + assert.equal(service.validationCalls[0]?.location.directory, "/mnt/d/workspace") + assert.equal(service.validationCalls[0]?.options?.kind, "lifecycle") + assert.match( + service.validationCalls[0]?.options?.identity ?? "", + /^wsl:ubuntu:\/home\/dev\/opencode:env:[a-f0-9]{64}$/, + ) + assert.equal( + service.validationCalls[0]?.options?.kind === "lifecycle" + ? service.validationCalls[0].options.lifecycle + : undefined, + lifecycle, + ) + const record = [...(manager as any).workspaces.values()][0] + assert.equal(record.wslDistro, "Ubuntu") }) it("shares one in-flight logical location creation", async () => { const harness = createHarness() @@ -163,28 +216,51 @@ describe("workspace manager shared service lifecycle", () => { const creation = harness.manager.create(process.cwd()) await harness.service.validationStarted.promise const record = [...(harness.manager as any).workspaces.values()][0] - const deletion = harness.manager.delete(record.id) + let deletion: Promise | undefined + harness.service.afterValidation = () => { deletion = harness.manager.delete(record.id) } + harness.service.validationGate.resolve() await assert.rejects(creation, WorkspaceLaunchCancelledError) await deletion assert.deepEqual(harness.manager.list(), []) - assert.deepEqual(harness.service.evictions, [{ directory: process.cwd() }]) + assert.equal(harness.service.evictionCalls.length, 1) + assert.equal(harness.service.evictionCalls[0]?.location.workspaceID, "location-1") + assert.equal(harness.service.evictionCalls[0]?.signal?.aborted, false) }) - it("keeps a failed eviction retryable and reports shutdown failures", async () => { + it("evicts a ready location on explicit final deletion without stopping the daemon", async () => { const harness = createHarness() - const { workspace } = await harness.manager.create(process.cwd()) - harness.service.failEvictions = 1 + const created = await harness.manager.create(process.cwd()) + await harness.manager.delete(created.workspace.id) - await assert.rejects(harness.manager.shutdown(), (error: unknown) => { - assert.ok(error instanceof WorkspaceShutdownError) - assert.match(String(error.errors[0]), /controlled eviction failure/) - return true + assert.equal(harness.service.evictionCalls.length, 1) + assert.deepEqual(harness.service.evictionCalls[0]?.location, { + directory: process.cwd(), + workspaceID: "location-1", }) - assert.equal(harness.manager.get(workspace.id)?.status, "ready") + assert.equal(harness.service.shutdownCalls, 0) + }) - await harness.manager.delete(workspace.id) - assert.equal(harness.manager.get(workspace.id), undefined) + it("does not evict a reused workspace that remains owned", async () => { + const harness = createHarness() + const retained = await harness.manager.create(process.cwd()) + const reused = await harness.manager.create(process.cwd(), undefined, { requestId: "restore" }) + + assert.equal(reused.workspace.id, retained.workspace.id) + assert.equal(reused.created, false) + await harness.manager.cancelCreationRequest("restore") + assert.equal(harness.service.evictionCalls.length, 0) + assert.equal(harness.manager.list().length, 1) + }) + + it("shuts down only the local adapter after deleting workspaces", async () => { + const harness = createHarness() + await harness.manager.create(process.cwd()) + + await harness.manager.shutdown() + + assert.deepEqual(harness.manager.list(), []) + assert.equal(harness.service.shutdownCalls, 1) }) }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 00590d9f..0ec55a77 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,5 +1,4 @@ import path from "path" -import os from "node:os" import { spawnSync } from "child_process" import { randomUUID } from "node:crypto" import type { Endpoint } from "@opencode-ai/client/service" @@ -18,32 +17,33 @@ import { parseWslUncPath, resolveWslHostDirectory, resolveWslServiceDirectory, + type ServiceLaunchSpec, } from "./spawn" -import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service" import { - prepareServiceState, - SERVICE_LEASE_DIRECTORY, - SERVICE_REGISTRATION_FILE, - SERVICE_STATE_ROOT, - SERVICE_STOP_LOCK, -} from "./service-state" + HostOpenCodeService, + hostOpenCodeServiceIdentity, + startupEnvironmentHash, +} from "./host-opencode-service" +import { + OpenCodeSharedService, + type OpenCodeServiceLifecycle, + type OpenCodeSharedServiceOptions, +} from "./opencode-service" +import { WslOpenCodeService } from "./wsl-opencode-service" import { isPathOwnedByWorktree, resolveWorktreeSlugForDirectory } from "./worktree-directory" const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 -const OPENCODE_DATABASE = path.join(os.homedir(), ".local", "share", "opencode2", "opencode.db") const ORDINARY_CREATION_OWNER = "" const WORKSPACE_STATE = Symbol("workspaceState") -const SERVICE_CONTENDER_FILE = path.join(SERVICE_STATE_ROOT, `contenders-${process.pid}-${randomUUID()}.txt`) -const SERVICE_LEASE_FILE = path.join(SERVICE_LEASE_DIRECTORY, `process-${process.pid}-${randomUUID()}.json`) type ManagerTimeout = number | NodeJS.Timeout interface SharedService { - endpoint: (options?: OpenCodeEnsureOptions) => Promise - client: (options?: OpenCodeEnsureOptions) => Promise - headers: (options?: OpenCodeEnsureOptions) => Promise<{ authorization: string } | undefined> - validateLocation: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise - subscribe: (requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise> - evict: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise + endpoint: (options?: OpenCodeSharedServiceOptions) => Promise + client: (options?: OpenCodeSharedServiceOptions) => Promise + headers: (options?: OpenCodeSharedServiceOptions) => Promise<{ authorization: string } | undefined> + validateLocation: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, serviceOptions?: OpenCodeSharedServiceOptions) => Promise + evictLocation: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, serviceOptions?: OpenCodeSharedServiceOptions) => Promise + subscribe: (requestOptions?: { signal?: AbortSignal }, serviceOptions?: OpenCodeSharedServiceOptions) => Promise> shutdown: (options?: { timeoutMs?: number }) => Promise } @@ -67,8 +67,6 @@ interface WorkspaceManagerOptions { binaryResolver: BinaryResolver eventBus: EventBus logger: Logger - /** Optional CA bundle path to trust CodeNomad HTTPS certs. */ - nodeExtraCaCertsPath?: string sharedService?: SharedService shutdownTimeoutMs?: number launchSettlementTimeoutMs?: number @@ -78,6 +76,16 @@ interface WorkspaceManagerOptions { platform?: NodeJS.Platform wslServiceDirectoryResolver?: (directory: string, distro: string, timeoutMs: number) => string | null wslHostDirectoryResolver?: (directory: string, distro: string, timeoutMs: number) => string | null + wslServiceLifecycleFactory?: ( + spec: Extract, + timeoutMs: number, + startupEnvironment: NodeJS.ProcessEnv, + ) => OpenCodeServiceLifecycle + hostServiceLifecycleFactory?: ( + spec: Extract, + timeoutMs: number, + startupEnvironment: NodeJS.ProcessEnv, + ) => OpenCodeServiceLifecycle } interface WorkspaceRecord extends WorkspaceDescriptor { @@ -95,6 +103,8 @@ interface WorkspaceState { deletePromise?: Promise published: boolean stoppedPublished: boolean + locationOwned: boolean + serviceOptions?: OpenCodeSharedServiceOptions } export class WorkspaceLaunchCancelledError extends Error { constructor(workspaceId: string) { @@ -142,6 +152,7 @@ export class WorkspaceManager { private shuttingDown = false private readonly sharedService: SharedService private serviceAuthorization?: string + private warnedLegacyServiceEnvironment = false constructor(private readonly options: WorkspaceManagerOptions) { this.sharedService = options.sharedService ?? new OpenCodeSharedService() @@ -403,7 +414,12 @@ export class WorkspaceManager { identityKey: { value: identityKey }, ownership: { value: ownership }, wslDistro: { value: undefined, writable: true }, - [WORKSPACE_STATE]: { value: { abortController: new AbortController(), published: false, stoppedPublished: false } }, + [WORKSPACE_STATE]: { value: { + abortController: new AbortController(), + published: false, + stoppedPublished: false, + locationOwned: false, + } }, }) this.workspaces.set(id, record) @@ -462,49 +478,42 @@ export class WorkspaceManager { const state = record[WORKSPACE_STATE] const { id, path: workspacePath, binaryId: resolvedBinaryPath } = record try { - const serverConfig = this.options.settings.getOwner("config", "server") - const configuredEnvironment = this.readConfiguredEnvironment(serverConfig) - if (this.options.nodeExtraCaCertsPath) configuredEnvironment.NODE_EXTRA_CA_CERTS = this.options.nodeExtraCaCertsPath - configuredEnvironment.XDG_STATE_HOME = SERVICE_STATE_ROOT - const serviceEnvironment = { ...process.env, ...configuredEnvironment } - // ponytail: fixed V2 storage root until database selection needs to be configurable. - serviceEnvironment.OPENCODE_DB = OPENCODE_DATABASE - prepareServiceState(SERVICE_CONTENDER_FILE) - const launch = buildServiceLaunchSpec(resolvedBinaryPath, ["serve", "--service"], { - env: serviceEnvironment, - propagateEnvKeys: Object.keys(configuredEnvironment), - contenderFile: SERVICE_CONTENDER_FILE, + const launch = buildServiceLaunchSpec(resolvedBinaryPath, { platform: this.options.platform, }) - const ensureOptions: OpenCodeEnsureOptions = { - file: SERVICE_REGISTRATION_FILE, - command: launch.command, - contenderFile: SERVICE_CONTENDER_FILE, - leaseFile: SERVICE_LEASE_FILE, - lockDirectory: SERVICE_STOP_LOCK, - nativePid: launch.nativePid, - wslDistro: launch.wslDistro, - environment: launch.env, - launcherRecordsPid: launch.launcherRecordsPid, - windowsVerbatimArguments: launch.windowsVerbatimArguments, - timeoutMs, + const startupEnvironment = this.serviceStartupEnvironment() + const serviceOptions: OpenCodeSharedServiceOptions = { + kind: "lifecycle", + identity: launch.kind === "host" + ? hostOpenCodeServiceIdentity({ + binary: launch.binary, + platform: launch.platform, + startupEnvironment, + }) + : `wsl:${launch.distro.trim().toLowerCase()}:${path.posix.normalize(launch.binary)}` + + `:env:${startupEnvironmentHash(startupEnvironment, "linux")}`, + lifecycle: launch.kind === "host" + ? this.createHostServiceLifecycle(launch, timeoutMs, startupEnvironment) + : this.createWslServiceLifecycle(launch, timeoutMs, startupEnvironment), } + state.serviceOptions = serviceOptions this.throwIfCancelled(record) - record.wslDistro = launch.wslDistro - const serviceDirectory = launch.wslDistro - ? this.requireWslServiceDirectory(workspacePath, launch.wslDistro, timeoutMs) + record.wslDistro = launch.kind === "wsl" ? launch.distro : undefined + const serviceDirectory = launch.kind === "wsl" + ? this.requireWslServiceDirectory(workspacePath, launch.distro, timeoutMs) : workspacePath record.location = { directory: serviceDirectory } const [headers, location] = await Promise.all([ - this.sharedService.headers(ensureOptions), + this.sharedService.headers(serviceOptions), this.sharedService.validateLocation( { directory: serviceDirectory }, { signal: state.abortController.signal }, - ensureOptions, + serviceOptions, ), ]) this.serviceAuthorization = headers?.authorization record.location = { directory: location.directory, workspaceID: location.workspaceID } + state.locationOwned = true this.throwIfCancelled(record) state.published = true this.options.eventBus.publish({ type: "workspace.created", workspace: record }) @@ -517,10 +526,15 @@ export class WorkspaceManager { return { workspace: record, created: true } } catch (error) { const launchFailure = state.abortController.signal.aborted ? state.abortController.signal.reason : error - if (!state.deletePromise) { - await this.evictLocationIfUnused(record).catch((cleanupError) => { - this.options.logger.warn({ workspaceId: id, err: cleanupError }, "Failed to evict rejected workspace location") + if (state.locationOwned) { + await this.evictRecordLocation(record, timeoutMs).catch((evictionError) => { + this.options.logger.warn( + { workspaceId: id, err: evictionError }, + "Failed to evict an OpenCode location after workspace launch failure", + ) }) + } + if (!state.deletePromise && !state.locationOwned) { this.removeRecord(id, record, state.published) } this.options.logger.error({ workspaceId: id, err: launchFailure }, "Workspace failed to start") @@ -674,20 +688,26 @@ export class WorkspaceManager { } private async cleanupDeletedWorkspace(id: string, record: WorkspaceRecord): Promise { - await this.withTimeout(record[WORKSPACE_STATE].settlement!, this.options.launchSettlementTimeoutMs ?? 5000, `${id} launch cancellation`) - await this.evictLocationIfUnused(record) + const timeoutMs = Math.max(1, this.options.launchSettlementTimeoutMs ?? 5000) + await this.withTimeout(record[WORKSPACE_STATE].settlement!, timeoutMs, `${id} launch cancellation`) + await this.evictRecordLocation(record, timeoutMs) this.removeRecord(id, record, true) return record } - private async evictLocationIfUnused(record: WorkspaceRecord): Promise { - if (!record.location) return - const peers = Array.from(this.workspaces.values()).filter((candidate) => { - return candidate !== record && candidate.identityKey === record.identityKey - }) - if (peers.some((candidate) => !candidate[WORKSPACE_STATE].deletePromise)) return - if (peers.some((candidate) => candidate.id < record.id)) return - await this.sharedService.evict(record.location) + private async evictRecordLocation(record: WorkspaceRecord, timeoutMs: number): Promise { + const state = record[WORKSPACE_STATE] + if (!state.locationOwned || !record.location) return + await this.withTimeout( + this.sharedService.evictLocation( + record.location, + { signal: AbortSignal.timeout(timeoutMs) }, + state.serviceOptions, + ), + timeoutMs, + `${record.id} location eviction`, + ) + state.locationOwned = false } private requireWslServiceDirectory(directory: string, distro: string, timeoutMs = DEFAULT_LAUNCH_TIMEOUT_MS): string { @@ -712,6 +732,67 @@ export class WorkspaceManager { return resolveWslHostDirectory(directory, distro, undefined, timeoutMs) } + private createWslServiceLifecycle( + spec: Extract, + timeoutMs: number, + startupEnvironment: NodeJS.ProcessEnv, + ): OpenCodeServiceLifecycle { + return this.options.wslServiceLifecycleFactory?.(spec, timeoutMs, startupEnvironment) + ?? new WslOpenCodeService({ + distro: spec.distro, + binary: spec.binary, + startupEnvironment, + timeoutMs, + }) + } + + private createHostServiceLifecycle( + spec: Extract, + timeoutMs: number, + startupEnvironment: NodeJS.ProcessEnv, + ): OpenCodeServiceLifecycle { + return this.options.hostServiceLifecycleFactory?.(spec, timeoutMs, startupEnvironment) + ?? new HostOpenCodeService({ + binary: spec.binary, + platform: spec.platform, + startupEnvironment, + timeoutMs, + }) + } + + private serviceStartupEnvironment(): NodeJS.ProcessEnv { + const configured = this.options.settings.getOwner("config", "server").environmentVariables + const environment: NodeJS.ProcessEnv = {} + const omitted: string[] = [] + if (configured && typeof configured === "object" && !Array.isArray(configured)) { + for (const [key, value] of Object.entries(configured)) { + if (typeof value !== "string") continue + if (["OPENCODE_DB", "XDG_STATE_HOME"].includes(key.toUpperCase())) { + omitted.push(key) + continue + } + if (!key || key.includes("=") || key.includes("\0") || value.includes("\0")) { + throw new Error(`Invalid OpenCode service environment variable name: ${key || "(empty)"}`) + } + environment[key] = value + } + } + if (omitted.length && !this.warnedLegacyServiceEnvironment) { + this.warnedLegacyServiceEnvironment = true + this.options.logger.warn( + { variables: omitted }, + "Ignoring legacy OpenCode storage ownership variables for the global daemon", + ) + } + if (process.env.NODE_EXTRA_CA_CERTS !== undefined) { + for (const key of Object.keys(environment)) { + if (key.toUpperCase() === "NODE_EXTRA_CA_CERTS") delete environment[key] + } + environment.NODE_EXTRA_CA_CERTS = process.env.NODE_EXTRA_CA_CERTS + } + return environment + } + private removeRecord(id: string, record: WorkspaceRecord, publishStopped: boolean): void { if (this.workspaces.get(id) !== record) return this.workspaces.delete(id) @@ -728,15 +809,6 @@ export class WorkspaceManager { this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: record.id, reason }) } - private readConfiguredEnvironment(serverConfig: unknown): NodeJS.ProcessEnv { - if (!serverConfig || typeof serverConfig !== "object" || Array.isArray(serverConfig)) return {} - const environment = (serverConfig as { environmentVariables?: unknown }).environmentVariables - if (!environment || typeof environment !== "object" || Array.isArray(environment)) return {} - return Object.fromEntries( - Object.entries(environment).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ) - } - resolveBinaryPath(identifier: string, timeoutMs = DEFAULT_LAUNCH_TIMEOUT_MS): string { if (!identifier) { return identifier diff --git a/packages/server/src/workspaces/opencode-cli-service.ts b/packages/server/src/workspaces/opencode-cli-service.ts new file mode 100644 index 00000000..a22f29e2 --- /dev/null +++ b/packages/server/src/workspaces/opencode-cli-service.ts @@ -0,0 +1,265 @@ +import { execFile as nodeExecFile } from "node:child_process" +import { Service, type Endpoint } from "@opencode-ai/client/service" + +import { assertLoopbackServiceUrl } from "./service-state" +import type { OpenCodeServiceLifecycle } from "./opencode-service" +import type { SpawnSpec } from "./spawn" + +export const MAX_SERVICE_OUTPUT_BYTES = 64 * 1024 +const MAX_ERROR_CHARS = 1_024 + +export interface ServiceExecOptions { + encoding: "utf8" + maxBuffer: number + shell: false + timeout: number + windowsHide: true + cwd?: string + env?: NodeJS.ProcessEnv + windowsVerbatimArguments?: boolean +} + +export interface ServiceExecResult { + stdout: string + stderr: string +} + +export interface OpenCodeCliServiceDependencies { + execFile: (file: string, args: string[], options: ServiceExecOptions) => Promise + fetch: typeof globalThis.fetch +} + +export interface OpenCodeCliServiceOptions { + label: string + timeoutMs: number + command: (args: string[], start: boolean) => SpawnSpec + beforeHealth?: (endpoint: Endpoint, deadlineAt: number) => Promise + unreachableMessage?: (url: string) => string +} + +export class OpenCodeCliService implements OpenCodeServiceLifecycle { + private readonly dependencies: OpenCodeCliServiceDependencies + private readonly timeoutMs: number + + constructor( + private readonly options: OpenCodeCliServiceOptions, + dependencies: Partial = {}, + ) { + this.timeoutMs = Math.max(1, options.timeoutMs) + this.dependencies = { execFile: executeFile, fetch: globalThis.fetch, ...dependencies } + } + + async discover(): Promise { + const deadlineAt = Date.now() + this.timeoutMs + const status = this.singleLine(await this.run(["service", "status"], false, deadlineAt), "status") + if (status === "stopped") return undefined + return this.endpoint(status, deadlineAt) + } + + async ensure(): Promise { + const deadlineAt = Date.now() + this.timeoutMs + const url = this.singleLine(await this.run(["service", "start"], true, deadlineAt), "start") + return this.endpoint(url, deadlineAt) + } + + private async endpoint(url: string, deadlineAt: number): Promise { + this.assertServiceUrl(url) + const password = this.singleLine( + await this.run(["service", "get", "password"], false, deadlineAt), + "password", + ) + if (!password) throw new Error(`${this.options.label} OpenCode service returned an empty password`) + const endpoint: Endpoint = { url, auth: { type: "basic", username: "opencode", password } } + await this.options.beforeHealth?.(endpoint, deadlineAt) + await this.validateHealth(endpoint, deadlineAt) + return endpoint + } + + private async run(args: string[], start: boolean, deadlineAt: number): Promise { + const commandLabel = args.join(" ") + const timeout = this.remaining(deadlineAt, commandLabel) + const spec = this.options.command(args, start) + const options: ServiceExecOptions = { + encoding: "utf8", + maxBuffer: MAX_SERVICE_OUTPUT_BYTES, + shell: false, + timeout, + windowsHide: true, + ...(spec.cwd ? { cwd: spec.cwd } : {}), + ...(spec.env ? { env: spec.env } : {}), + ...(spec.options.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + } + try { + const result = await this.withDeadline( + this.dependencies.execFile(spec.command, spec.args, options), + deadlineAt, + commandLabel, + ) + return result.stdout + } catch (error) { + if (start || commandLabel === "service get password") { + const operation = start ? "start" : "password retrieval" + const code = safeNumericExecCode(error) + throw new Error( + `${this.options.label} OpenCode ${operation} failed${code === undefined ? "" : ` (exit code ${code})`}`, + ) + } + const detail = boundedExecError(error) + throw new Error(`${this.options.label} OpenCode ${commandLabel} failed${detail ? `: ${detail}` : ""}`) + } + } + + private async validateHealth(endpoint: Endpoint, deadlineAt: number): Promise { + let response: Response + try { + const timeout = this.remaining(deadlineAt, "health validation") + response = await this.withDeadline(this.dependencies.fetch(new URL("/api/health", endpoint.url), { + headers: Service.headers(endpoint), + signal: AbortSignal.timeout(timeout), + }), deadlineAt, "health validation") + } catch { + const message = this.options.unreachableMessage?.(endpoint.url) + throw new Error(message ?? `Cannot reach the ${this.options.label} OpenCode service at ${endpoint.url}`) + } + if (response.status === 401) { + throw new Error(`${this.options.label} OpenCode service authentication failed at ${endpoint.url} (HTTP 401)`) + } + if (!response.ok) { + throw new Error(`${this.options.label} OpenCode service health check failed at ${endpoint.url} (HTTP ${response.status})`) + } + + let health: unknown + try { + const body = await this.withDeadline( + readBoundedBody(response, MAX_SERVICE_OUTPUT_BYTES), + deadlineAt, + "health response", + ) + health = JSON.parse(body) + } catch { + throw new Error(`${this.options.label} OpenCode service returned an invalid health response at ${endpoint.url}`) + } + const value = health as { healthy?: unknown; version?: unknown; pid?: unknown } | null + if ( + !value + || typeof value !== "object" + || value.healthy !== true + || typeof value.version !== "string" + || !value.version.trim() + || !Number.isSafeInteger(value.pid) + || Number(value.pid) <= 0 + ) { + throw new Error(`${this.options.label} OpenCode service is not API-compatible at ${endpoint.url}`) + } + } + + private assertServiceUrl(value: string): void { + let url: URL + try { + url = assertLoopbackServiceUrl(value) + } catch { + throw new Error(`${this.options.label} OpenCode service returned an invalid loopback URL`) + } + if (/[^\S\r\n]|[\x00-\x1f\x7f]/.test(value) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + throw new Error(`${this.options.label} OpenCode service returned an invalid loopback URL`) + } + } + + private singleLine(value: string, label: string): string { + const line = value.endsWith("\r\n") ? value.slice(0, -2) + : value.endsWith("\n") || value.endsWith("\r") ? value.slice(0, -1) + : value + if (line.includes("\n") || line.includes("\r")) { + throw new Error(`${this.options.label} OpenCode service returned multiline ${label} output`) + } + if (line !== line.trim()) { + throw new Error(`${this.options.label} OpenCode service returned malformed ${label} output`) + } + return line + } + + private remaining(deadlineAt: number, label: string): number { + const remaining = deadlineAt - Date.now() + if (remaining <= 0) { + throw new Error(`${this.options.label} OpenCode ${label} timed out after ${this.timeoutMs}ms`) + } + return remaining + } + + private async withDeadline(operation: Promise, deadlineAt: number, label: string): Promise { + const timeoutMs = this.remaining(deadlineAt, label) + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`${this.options.label} OpenCode ${label} timed out after ${this.timeoutMs}ms`)), + timeoutMs, + ) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } + } +} + +function executeFile(file: string, args: string[], options: ServiceExecOptions): Promise { + return new Promise((resolve, reject) => { + nodeExecFile(file, args, options, (error, stdout, stderr) => { + if (error) { + Object.assign(error, { stdout, stderr }) + reject(error) + } else { + resolve({ stdout, stderr }) + } + }) + }) +} + +function boundedExecError(error: unknown): string { + if (!error || typeof error !== "object") return clip(String(error)) + const value = error as { code?: unknown; message?: unknown; stdout?: unknown; stderr?: unknown } + return clip([ + value.code === undefined ? "" : `code ${clip(String(value.code))}`, + value.message === undefined ? "" : clip(String(value.message)), + value.stderr === undefined ? "" : clip(String(value.stderr).trim()), + value.stdout === undefined ? "" : clip(String(value.stdout).trim()), + ].filter(Boolean).join(": ")) +} + +function safeNumericExecCode(error: unknown): number | undefined { + if (!error || typeof error !== "object") return undefined + const code = (error as { code?: unknown }).code + return typeof code === "number" && Number.isSafeInteger(code) ? code : undefined +} + +async function readBoundedBody(response: Response, maxBytes: number): Promise { + if (!response.body) return "" + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (total + value.byteLength > maxBytes) { + await reader.cancel().catch(() => undefined) + throw new Error("Response body exceeds limit") + } + chunks.push(value) + total += value.byteLength + } + } catch (error) { + await reader.cancel().catch(() => undefined) + throw error + } finally { + reader.releaseLock() + } + return Buffer.concat(chunks, total).toString("utf8") +} + +function clip(value: string): string { + return value.length <= MAX_ERROR_CHARS ? value : `${value.slice(0, MAX_ERROR_CHARS)}...` +} diff --git a/packages/server/src/workspaces/opencode-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts index 1d119fa6..67aa6085 100644 --- a/packages/server/src/workspaces/opencode-service.test.ts +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -1,588 +1,142 @@ import assert from "node:assert/strict" -import { access, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises" -import os from "node:os" -import path from "node:path" import { describe, it } from "node:test" -import { createHash } from "node:crypto" -import { OpenCode, type OpenCodeClient } from "@opencode-ai/client" -import type { Endpoint, Info } from "@opencode-ai/client/service" +import type { OpenCodeClient } from "@opencode-ai/client" +import type { Endpoint } from "@opencode-ai/client/service" -import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service" -import type { ProcessIdentity, ProcessIdentityProbe, ProcessNamespace } from "./process-identity" +import { + OpenCodeSharedService, + type OpenCodeServiceLifecycle, + type OpenCodeSharedServiceDependencies, + type OpenCodeSharedServiceOptions, +} from "./opencode-service" + +const endpoint: Endpoint = { + url: "http://127.0.0.1:4321", + auth: { type: "basic", username: "opencode", password: "secret" }, +} describe("OpenCodeSharedService", () => { - it("rejects a changed launch signature instead of reusing the connected daemon", async () => { - const endpoint = { url: "http://127.0.0.1:4321", auth: undefined } - const service = new OpenCodeSharedService({ - discover: async () => endpoint, - ensure: async () => endpoint, - headers: () => undefined, - makeClient: () => ({} as OpenCodeClient), - }) - await service.endpoint({ version: "0.0.0-next-17444", command: ["first"], environment: { OPENCODE_DB: "/one" } }) - await assert.rejects( - service.endpoint({ version: "0.0.0-next-17444", command: ["first"], environment: { OPENCODE_DB: "/two" } }), - /launch configuration/, - ) - }) - - it("validates a caller workspace selector against the canonical location", async () => { - const service = new OpenCodeSharedService({ - discover: async () => undefined, - ensure: async () => ({ url: "http://127.0.0.1:4321" }), - headers: () => undefined, - makeClient: () => ({ location: { get: async () => ({ - directory: "/repo", workspaceID: "canonical", project: { id: "p", directory: "/repo", canonical: "/repo" }, - }) } }) as unknown as OpenCodeClient, - }) - await assert.rejects(service.validateLocation({ directory: "/repo", workspaceID: "foreign" }), /does not match/) - }) - - it("rejects malformed endpoints and locations", async () => { - const invalidEndpoint = new OpenCodeSharedService({ - discover: async () => undefined, - ensure: async () => ({ url: "file:///tmp/opencode" }), - headers: () => undefined, - makeClient: () => { throw new Error("client should not be created") }, - }) - await assert.rejects(invalidEndpoint.endpoint(), /Unsupported OpenCode service protocol/) - - const remoteEndpoint = new OpenCodeSharedService({ - discover: async () => undefined, - ensure: async () => ({ url: "http://192.0.2.1:4321" }), - headers: () => undefined, - makeClient: () => { throw new Error("client should not be created") }, - }) - await assert.rejects(remoteEndpoint.endpoint(), /must be loopback/) - - const invalidLocation = new OpenCodeSharedService({ - discover: async () => undefined, - ensure: async () => ({ url: "http://localhost:4321" }), - headers: () => undefined, - makeClient: () => ({ location: { get: async () => ({ directory: "/repo" }) } }) as unknown as OpenCodeClient, - }) - await assert.rejects(invalidLocation.validateLocation({ directory: "/repo" }), /invalid location/) - }) - - it("persists native PID proof through transfer and shutdown reconstruction", async () => { - const state = await serviceState("codenomad-service-peer-") - let stops = 0 - const owner = createOwnedService(state, async () => { stops += 1; return true }) - const peer = createOwnedService( - state, - async () => { stops += 1; return true }, - false, - (pid) => pid !== state.info.pid, - undefined, - true, - ) - try { - await owner.endpoint(state.options("owner", true)) - assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, true) - await peer.endpoint(state.options("peer", false)) - await owner.shutdown() - assert.equal(stops, 0) - assert.equal(JSON.parse(await readFile(state.lease("peer"), "utf8")).service.nativePid, true) - assert.equal((await peer.endpoint()).url, state.info.url) - await peer.shutdown() - assert.equal(stops, 1) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("reclaims a bounded stale lifecycle lock after PID reuse", async () => { - const state = await serviceState("codenomad-service-stale-lock-") - const stalePid = 7654321 - await mkdir(state.lockDirectory) - await writeFile(path.join(state.lockDirectory, "owner.json"), JSON.stringify({ - version: 1, - identity: "stale-lock-owner", - pid: stalePid, - processIdentity: processIdentity(stalePid, "previous-process"), - createdAt: 1, - })) - await Promise.all([ - utimes(path.join(state.lockDirectory, "owner.json"), 1, 1), - utimes(state.lockDirectory, 1, 1), - ]) - const service = createOwnedService(state, async () => true, true, () => true) - try { - await service.endpoint({ ...state.options("owner", true), staleLockMs: 1 }) - await assert.rejects(access(state.lockDirectory)) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("prunes an identity-checked lease after PID reuse", async () => { - const state = await serviceState("codenomad-service-stale-lease-") - const stalePid = 7654321 - const staleLease = path.join(state.leases, "stale.json") - await writeFile(staleLease, JSON.stringify({ - version: 1, - identity: "stale-peer", - pid: stalePid, - processIdentity: processIdentity(stalePid, "previous-process"), - createdAt: 1, - updatedAt: 1, - state: "active", - })) - let stops = 0 - const service = createOwnedService(state, async () => { stops += 1; return true }, true, () => true) - try { - await service.endpoint(state.options("owner", true)) - await service.shutdown() - assert.equal(stops, 1) - await assert.rejects(access(staleLease)) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("quarantines stale registration after deterministic PID reuse without signaling it", async () => { - const state = await serviceState("codenomad-service-stale-registration-") - const reusedPid = state.info.pid - const options = { - ...state.options("successor", false), - command: [process.execPath, "-e", "process.exit(0)"], - timeoutMs: 50, - } - const launchSignature = signature(options) - await writeFile(state.lease("dead-owner"), JSON.stringify({ - version: 1, - identity: "dead-owner", - pid: 7654321, - processIdentity: processIdentity(7654321, "dead-codenomad"), - createdAt: 1, - updatedAt: 1, - state: "active", - launchSignature, - service: { - info: state.info, - endpoint: { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }, - registrationFile: state.file, - nativePid: true, - processIdentity: processIdentity(reusedPid, "old-service-process"), - launchSignature, - }, - })) - const service = new OpenCodeSharedService({ - discover: async () => undefined, - headers: () => undefined, - isProcessAlive: () => true, - getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity( - pid, - pid === reusedPid ? "reused-service-process" : `identity-${pid}`, - namespace, - ), - makeClient: () => ({} as OpenCodeClient), - }) - try { - await assert.rejects(service.endpoint(options), /exited before registration|timed out/) - await assert.rejects(access(state.file)) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("prunes old invalid lease artifacts while preserving fresh writes", async () => { - const state = await serviceState("codenomad-service-invalid-leases-") - const oldLease = state.lease("invalid") - const legacyLease = state.lease("legacy") - const oldTemporary = `${state.lease("peer")}.peer-id.tmp` - const freshLease = state.lease("fresh") - await writeFile(oldLease, "{") - await writeFile(legacyLease, JSON.stringify({ - version: 1, - identity: "legacy", - pid: 1234, - createdAt: 1, - updatedAt: 1, - state: "active", - })) - await writeFile(oldTemporary, "partial") - await writeFile(freshLease, "{") - await Promise.all([utimes(oldLease, 1, 1), utimes(legacyLease, 1, 1), utimes(oldTemporary, 1, 1)]) - const service = createOwnedService(state, async () => true) - try { - await service.endpoint({ ...state.options("owner", true), staleLockMs: 60_000 }) - await assert.rejects(service.shutdown({ timeoutMs: 20 }), /invalid identity metadata/) - await Promise.all([ - assert.rejects(access(oldLease)), - assert.rejects(access(legacyLease)), - assert.rejects(access(oldTemporary)), - ]) - await access(freshLease) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("inherits proven ownership from a dead owner lease", async () => { - const state = await serviceState("codenomad-service-dead-owner-") - const deadPid = 7654321 - const options = state.options("peer", false) - const launchSignature = signature(options) - await writeFile(state.lease("dead-owner"), JSON.stringify({ - version: 1, - identity: "dead-owner", - pid: deadPid, - processIdentity: processIdentity(deadPid), - createdAt: 1, - updatedAt: 1, - state: "active", - launchSignature, - service: { - info: state.info, - endpoint: { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }, - registrationFile: state.file, - nativePid: true, - launchSignature, - }, - })) - let stops = 0 - const peer = createOwnedService(state, async () => { stops += 1; return true }, false, (pid) => pid !== deadPid) - try { - await peer.endpoint(options) - await peer.shutdown() - assert.equal(stops, 1) - await assert.rejects(access(state.lease("dead-owner"))) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("rejects stale ownership proof from a daemon launched with a different OPENCODE_DB", async () => { - const state = await serviceState("codenomad-service-stale-signature-") - const owner = createOwnedService(state, async () => true) - const successor = createOwnedService(state, async () => true, false, () => true) - const deadPid = 7654321 - try { - await owner.endpoint({ ...state.options("dead-owner", true), environment: { OPENCODE_DB: "/db/one" } }) - const staleLease = JSON.parse(await readFile(state.lease("dead-owner"), "utf8")) - staleLease.pid = deadPid - staleLease.processIdentity = processIdentity(deadPid, "dead-codenomad") - await writeFile(state.lease("dead-owner"), JSON.stringify(staleLease)) - - await assert.rejects( - successor.endpoint({ ...state.options("successor", false), environment: { OPENCODE_DB: "/db/two" } }), - /does not match the discovered daemon/, - ) - await assert.rejects(access(state.lease("successor"))) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("stops the proven endpoint without following a registration swap", async () => { - const state = await serviceState("codenomad-service-swap-") - const replacement = { ...state.info, id: "replacement", pid: 9999, url: "http://127.0.0.1:9999" } - let requestedId: string | undefined - const service = createOwnedService(state, async (info: Info, endpoint: Endpoint) => { - await writeFile(state.file, JSON.stringify(replacement)) - requestedId = info.id - assert.equal(endpoint.url, state.info.url) - return true - }) - try { - await service.endpoint(state.options("owner", true)) - await service.shutdown() - assert.equal(requestedId, state.info.id) - assert.deepEqual(JSON.parse(await readFile(state.file, "utf8")), replacement) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("delegates proven host shutdown to Service.stop with the registration file", async () => { - const state = await serviceState("codenomad-service-sdk-stop-") - let stopFile: string | undefined - const service = new OpenCodeSharedService({ - discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }), - ensure: async (options) => { - options?.onStart?.("missing") - return { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } } - }, - stop: async (options) => { stopFile = options?.file }, - headers: () => undefined, - waitForStop: async () => true, - getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), - makeClient: () => ({} as OpenCodeClient), - }) - try { - await service.endpoint(state.options("owner", true)) - await service.shutdown() - assert.equal(stopFile, state.file) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("never owns a symlinked registration", { skip: process.platform === "win32" }, async () => { - const state = await serviceState("codenomad-service-link-") - const target = path.join(state.root, "target.json") - await writeFile(target, JSON.stringify(state.info)) - await rm(state.file) - await symlink(target, state.file) - let stops = 0 - const service = createOwnedService(state, async () => { stops += 1; return true }) - try { - await service.endpoint(state.options("owner", true)) - await service.shutdown() - assert.equal(stops, 0) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("rejects a wrapper launch without a proven service PID before connecting", async () => { - const state = await serviceState("codenomad-service-wrapper-") - let launches = 0 + it("discovers or starts one pinned CLI lifecycle", async () => { let discoveries = 0 - let stops = 0 - const service = new OpenCodeSharedService({ - discover: async () => { discoveries += 1; return undefined }, - ensure: async () => { launches += 1; return { url: state.info.url } }, - headers: () => undefined, - requestStop: async () => { stops += 1; return true }, - makeClient: () => ({} as OpenCodeClient), - }) - try { - await assert.rejects(service.endpoint({ - ...state.options("wrapper", true), - contenderFile: undefined, - nativePid: false, - }), /cannot prove the service PID/) - await service.shutdown() - assert.equal(launches, 0) - assert.equal(discoveries, 0) - assert.equal(stops, 0) - } finally { - await rm(state.root, { recursive: true, force: true }) + let starts = 0 + let running = false + const lifecycle: OpenCodeServiceLifecycle = { + discover: async () => { discoveries += 1; return running ? endpoint : undefined }, + ensure: async () => { starts += 1; running = true; return endpoint }, } + const service = createService() + const options = lifecycleOptions("host:test", lifecycle) + + await Promise.all([service.endpoint(options), service.client(options)]) + assert.equal(starts, 1) + assert.equal(discoveries, 1) + assert.equal(await service.endpoint(), endpoint) + assert.equal(discoveries, 2) }) - it("checks WSL stop completion in the distro despite a coincidental live Windows PID", async () => { - const state = await serviceState("codenomad-service-wsl-stop-") - let healthChecks = 0 - let sdkStops = 0 - let wslPidExists = true - const server = (await import("node:http")).createServer((request, response) => { - healthChecks += 1 - if (healthChecks <= 2) { - response.destroy() - return - } - response.setHeader("content-type", "application/json") - response.end(JSON.stringify({ healthy: true, version: "test", pid: state.info.pid })) - }) - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) - const address = server.address() - assert.ok(address && typeof address === "object") - state.info.url = `http://127.0.0.1:${address.port}` - await writeFile(state.file, JSON.stringify(state.info)) - const wslNamespace = { kind: "wsl", distro: "Ubuntu" } as const - const endpoint = { url: state.info.url, auth: { type: "basic" as const, username: "opencode", password: state.info.password } } - const service = new OpenCodeSharedService({ - discover: async () => endpoint, - ensure: async (options) => { options?.onStart?.("missing"); return endpoint }, - headers: () => undefined, - isProcessAlive: () => true, - getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), - probeProcessIdentity: async (pid, _timeoutMs, namespace) => wslPidExists - ? { status: "found", identity: processIdentity(pid, undefined, namespace) } - : { status: "missing" }, - makeClient: () => ({ health: { stop: async () => { sdkStops += 1; return { accepted: true } } } }) as unknown as OpenCodeClient, - }) - try { - await service.endpoint({ ...state.options("owner", true), nativePid: false, wslDistro: "Ubuntu" }) - assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, false) - assert.deepEqual(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.processIdentity.namespace, wslNamespace) - await assert.rejects(service.shutdown({ timeoutMs: 500 }), /did not exit|completion timed out/) - assert.equal(sdkStops, 1) - assert.ok(healthChecks > 2) - assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping") - wslPidExists = false - await service.shutdown({ timeoutMs: 100 }) - await assert.rejects(access(state.lease("owner"))) - } finally { - await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("never lets an unsupported WSL health stop fall back to a host PID signal", async () => { - const state = await serviceState("codenomad-service-wsl-no-host-signal-") - const originalKill = process.kill - const signals: Array<{ pid: number; signal?: NodeJS.Signals | number }> = [] - let healthStops = 0 - const server = (await import("node:http")).createServer((request, response) => { - response.setHeader("content-type", "application/json") - if (request.method === "GET" && request.url === "/api/health") { - response.end(JSON.stringify({ healthy: true, version: "test", pid: state.info.pid })) - return - } - if (request.method === "POST") healthStops += 1 - response.statusCode = 404 - response.end(JSON.stringify({ error: "unsupported" })) - }) - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) - const address = server.address() - assert.ok(address && typeof address === "object") - state.info.url = `http://127.0.0.1:${address.port}` - await writeFile(state.file, JSON.stringify(state.info)) - const endpoint = { url: state.info.url, auth: { type: "basic" as const, username: "opencode", password: state.info.password } } - const service = new OpenCodeSharedService({ - discover: async () => endpoint, - ensure: async (options) => { options?.onStart?.("missing"); return endpoint }, - headers: () => undefined, - getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), - probeProcessIdentity: async (pid, _timeoutMs, namespace) => ({ - status: "found", - identity: processIdentity(pid, undefined, namespace), - }), - makeClient: OpenCode.make, - }) - process.kill = ((pid: number, signal?: NodeJS.Signals | number) => { - signals.push({ pid, signal }) - return true - }) as typeof process.kill - try { - await service.endpoint({ ...state.options("owner", true), nativePid: false, wslDistro: "Ubuntu" }) - await assert.rejects(service.shutdown({ timeoutMs: 500 })) - assert.equal(healthStops, 1) - assert.deepEqual(signals, []) - assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping") - } finally { - process.kill = originalKill - await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("reports a failed stop and keeps an active retryable lease", async () => { - const state = await serviceState("codenomad-service-stop-failure-") - let stops = 0 - const service = createOwnedService(state, async () => { stops += 1; return stops > 1 }) - try { - await service.endpoint(state.options("owner", true)) - await assert.rejects(service.shutdown(), /failed the stop request/) - const lease = JSON.parse(await readFile(state.lease("owner"), "utf8")) - assert.equal(lease.state, "active") - await service.shutdown() - assert.equal(stops, 2) - await assert.rejects(access(state.lease("owner"))) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("bounds stalled ensure and stop operations", async () => { - const stalledEnsure = new OpenCodeSharedService({ - discover: async () => undefined, - ensure: async () => new Promise(() => undefined), - headers: () => undefined, - makeClient: () => ({} as OpenCodeClient), - }) - await assert.rejects(stalledEnsure.endpoint({ timeoutMs: 10 }), /timed out after 10ms/) - - const state = await serviceState("codenomad-service-stop-timeout-") - let stops = 0 - const stalledStop = createOwnedService( - state, - async () => { stops += 1; return new Promise(() => undefined) }, - true, - undefined, - async () => false, + it("requires configuration before discovery and rejects identity changes", async () => { + const service = createService() + await assert.rejects(service.endpoint(), /has not been configured/) + await service.endpoint(lifecycleOptions("host:first", lifecycleFor(endpoint))) + await assert.rejects( + service.endpoint(lifecycleOptions("host:second", lifecycleFor(endpoint))), + /identity cannot change/, ) - try { - await stalledStop.endpoint(state.options("owner", true)) - await assert.rejects(stalledStop.shutdown({ timeoutMs: 10 }), /stop timed out/) - assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping") - await assert.rejects(stalledStop.shutdown({ timeoutMs: 10 }), /uncertain outcome/) - assert.equal(stops, 1) - } finally { - await rm(state.root, { recursive: true, force: true }) - } }) + it("keeps the first lifecycle object for equivalent identities", async () => { + let firstCalls = 0 + let replacementCalls = 0 + const service = createService() + await service.endpoint(lifecycleOptions("host:same", { + discover: async () => { firstCalls += 1; return endpoint }, + ensure: async () => endpoint, + })) + await service.endpoint(lifecycleOptions("host:same", { + discover: async () => { replacementCalls += 1; return endpoint }, + ensure: async () => endpoint, + })) + assert.equal(firstCalls, 2) + assert.equal(replacementCalls, 0) + }) + + it("drops only local state on shutdown and fences a late startup", async () => { + let resolveStart!: (value: Endpoint) => void + let discoveries = 0 + const lifecycle: OpenCodeServiceLifecycle = { + discover: async () => { discoveries += 1; return discoveries === 1 ? undefined : endpoint }, + ensure: async () => new Promise((resolve) => { resolveStart = resolve }), + } + const service = createService() + const pending = service.endpoint(lifecycleOptions("host:test", lifecycle)) + await new Promise((resolve) => setImmediate(resolve)) + await service.shutdown() + resolveStart(endpoint) + assert.equal(await pending, endpoint) + assert.equal(await service.endpoint(lifecycleOptions("host:test", lifecycle)), endpoint) + assert.equal(discoveries, 2) + }) + + it("rejects non-loopback endpoints", async () => { + const service = createService() + await assert.rejects(service.endpoint(lifecycleOptions("host:test", lifecycleFor({ + url: "http://192.0.2.1:4321", + auth: undefined, + }))), /must be loopback/) + }) + + it("formats auth, validates locations, and evicts through the official debug API", async () => { + let clientHeaders: HeadersInit | undefined + let evicted: unknown + let evictionSignal: AbortSignal | undefined + const service = createService({ + makeClient: (options) => { + clientHeaders = options.headers + return { + location: { get: async () => ({ + directory: "/repo", + workspaceID: "canonical", + project: { id: "project", directory: "/repo", canonical: "/repo" }, + }) }, + debug: { location: { evict: async (input: unknown, request?: { signal?: AbortSignal }) => { + evicted = input + evictionSignal = request?.signal + } } }, + } as unknown as OpenCodeClient + }, + }) + const options = lifecycleOptions("host:test", lifecycleFor(endpoint)) + const signal = new AbortController().signal + + assert.deepEqual(await service.headers(options), { authorization: "Basic proxy" }) + await assert.rejects( + service.validateLocation({ directory: "/repo", workspaceID: "foreign" }, undefined, options), + /does not match/, + ) + await service.evictLocation( + { directory: "/repo", workspaceID: "canonical" }, + { signal }, + options, + ) + + assert.deepEqual(clientHeaders, { authorization: "Basic proxy" }) + assert.deepEqual(evicted, { location: { directory: "/repo", workspace: "canonical" } }) + assert.equal(evictionSignal, signal) + }) }) -async function serviceState(prefix: string) { - const root = await mkdtemp(path.join(os.tmpdir(), prefix)) - const file = path.join(root, "service.json") - const contenders = path.join(root, "contenders.txt") - const leases = path.join(root, "leases") - const lockDirectory = path.join(root, "stop.lock") - const info = { id: "instance-1", url: "http://127.0.0.1:4321", pid: 1234, password: "secret" } - await mkdir(leases) - await writeFile(file, JSON.stringify(info)) - await writeFile(contenders, `${info.pid}\n`) - return { - root, file, contenders, leases, lockDirectory, info, - lease(name: string) { return path.join(leases, `${name}.json`) }, - options(name: string, started: boolean): OpenCodeEnsureOptions { - return { - file, - contenderFile: contenders, - leaseFile: path.join(leases, `${name}.json`), - lockDirectory, - onStart: started ? () => undefined : undefined, - } - }, - } +function lifecycleOptions(identity: string, lifecycle: OpenCodeServiceLifecycle): OpenCodeSharedServiceOptions { + return { kind: "lifecycle", identity, lifecycle } } -function createOwnedService( - state: Awaited>, - requestStop: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise, - announceStart = true, - isProcessAlive?: (pid: number) => boolean, - waitForStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise, - useDefaultWaitForStop = false, - getProcessIdentity: ( - pid: number, - timeoutMs: number, - namespace?: ProcessNamespace, - ) => Promise = async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), - probeProcessIdentity?: ( - pid: number, - timeoutMs: number, - namespace?: ProcessNamespace, - ) => Promise, -) { +function lifecycleFor(value: Endpoint): OpenCodeServiceLifecycle { + return { discover: async () => value, ensure: async () => value } +} + +function createService(overrides: Partial = {}) { return new OpenCodeSharedService({ - discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }), - ensure: async (options) => { - if (announceStart) options?.onStart?.("missing") - return { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } } - }, - headers: () => undefined, - requestStop, - waitForStop: useDefaultWaitForStop ? undefined : waitForStop ?? (async () => true), - isProcessAlive, - getProcessIdentity, - probeProcessIdentity, + headers: () => ({ authorization: "Basic proxy" }), makeClient: () => ({} as OpenCodeClient), + ...overrides, }) } - -function processIdentity( - pid: number, - start = `identity-${pid}`, - namespace: ProcessNamespace = { kind: "host" }, -): ProcessIdentity { - return { namespace, pid, start } -} - -function signature(options: OpenCodeEnsureOptions): string { - const environment = Object.entries(options.environment ?? {}).sort(([left], [right]) => left.localeCompare(right)) - return createHash("sha256").update(JSON.stringify({ - command: options.command ?? [], - environment, - version: options.version ?? null, - wslDistro: options.wslDistro ?? null, - windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, - })).digest("hex") -} diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts index 59019971..84b9e313 100644 --- a/packages/server/src/workspaces/opencode-service.ts +++ b/packages/server/src/workspaces/opencode-service.ts @@ -5,150 +5,63 @@ import { type OpenCodeClient, type OpenCodeEvent, } from "@opencode-ai/client" -import { Service, type Endpoint, type EnsureOptions, type Info, type StopOptions } from "@opencode-ai/client/service" -import { spawn, type ChildProcess } from "node:child_process" -import { createHash, randomUUID } from "node:crypto" -import type { Stats } from "node:fs" -import { appendFile, lstat, mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises" -import path from "node:path" -import { - getProcessStartIdentity, - probeProcessStartIdentity, - type ProcessIdentity, - type ProcessNamespace, -} from "./process-identity" -import { assertLoopbackServiceUrl, readSecureServiceInfo } from "./service-state" +import { Service, type Endpoint } from "@opencode-ai/client/service" +import { assertLoopbackServiceUrl } from "./service-state" type RequestOptions = { signal?: AbortSignal } -type ServiceProof = { - info: Info - endpoint: Endpoint - registrationFile: string - nativePid?: boolean - processIdentity?: ProcessIdentity - launchSignature?: string -} -interface LaunchIntent { - identity: string - createdAt: number - nativePid: boolean - contenderFile?: string - servicePid?: number -} -type LeaseState = "active" | "stopping" -interface LeaseMetadata { - version: 1 - identity: string - pid: number - processIdentity: ProcessIdentity - createdAt: number - updatedAt: number - state: LeaseState - contenderFile?: string - launch?: LaunchIntent - service?: ServiceProof - launchSignature?: string -} -interface LockOwner { - version: 1 - identity: string - pid: number - processIdentity: ProcessIdentity - createdAt: number -} -interface LeaseHandle { - file: string - lockDirectory: string - contenderFile?: string - identity: string - staleLockMs: number - lifecycleTimeoutMs: number + +export interface OpenCodeServiceLifecycle { + discover: () => Promise + ensure: () => Promise } -const DEFAULT_STALE_LOCK_MS = 30_000 -const LOCK_OWNER_FILE = "owner.json" - -export type OpenCodeEnsureOptions = EnsureOptions & { - contenderFile?: string - leaseFile?: string - lockDirectory?: string - timeoutMs?: number - staleLockMs?: number - nativePid?: boolean - wslDistro?: string - environment?: NodeJS.ProcessEnv - launcherRecordsPid?: boolean - windowsVerbatimArguments?: boolean +export type OpenCodeSharedServiceOptions = { + kind: "lifecycle" + identity: string + lifecycle: OpenCodeServiceLifecycle } interface ServiceConnection { endpoint: Endpoint client: OpenCodeClient - stopOptions: StopOptions - launchSignature: string -} - -interface OwnedService { - stopOptions: StopOptions - info: Info - endpoint: Endpoint - nativePid: boolean - processIdentity?: ProcessIdentity - launchSignature: string } export interface OpenCodeSharedServiceDependencies { - discover: typeof Service.discover - ensure?: typeof Service.ensure - stop?: typeof Service.stop headers: typeof Service.headers makeClient: typeof OpenCode.make - requestStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise - waitForStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise - isProcessAlive?: (pid: number) => boolean - getProcessIdentity?: (pid: number, timeoutMs: number, namespace?: ProcessNamespace) => Promise - probeProcessIdentity?: typeof probeProcessStartIdentity } -type ProcessOwnerState = "live" | "stale" | "unknown" - export class OpenCodeSharedService { private connection?: Promise private connected?: ServiceConnection private healthCheck?: Promise - private pendingLaunch?: Promise - private ensureOptions?: OpenCodeEnsureOptions - private owned?: OwnedService - private lease?: LeaseHandle - private shutdownAttempt?: Promise - private shutdownRequested = false - private readonly pendingEvictions = new Map() + private serviceOptions?: OpenCodeSharedServiceOptions + private serviceIdentity?: string + private generation = 0 constructor(private readonly dependencies: OpenCodeSharedServiceDependencies = { - discover: Service.discover, - stop: Service.stop, headers: Service.headers, makeClient: OpenCode.make, }) {} - endpoint(options?: OpenCodeEnsureOptions): Promise { + endpoint(options?: OpenCodeSharedServiceOptions): Promise { return this.connect(options).then(({ endpoint }) => endpoint) } - client(options?: OpenCodeEnsureOptions): Promise { + client(options?: OpenCodeSharedServiceOptions): Promise { return this.connect(options).then(({ client }) => client) } - async headers(options?: OpenCodeEnsureOptions): Promise> { + async headers(options?: OpenCodeSharedServiceOptions): Promise> { return this.dependencies.headers(await this.endpoint(options)) } async validateLocation( location: LocationRef, requestOptions?: RequestOptions, - ensureOptions?: OpenCodeEnsureOptions, + serviceOptions?: OpenCodeSharedServiceOptions, ): Promise { - const result = await this.withClient(ensureOptions, (client) => client.location.get({ + const result = await this.withClient(serviceOptions, (client) => client.location.get({ location: { directory: location.directory }, }, requestOptions)) if ( @@ -166,227 +79,54 @@ export class OpenCodeSharedService { return result } - async subscribe(requestOptions?: RequestOptions, ensureOptions?: OpenCodeEnsureOptions): Promise> { + async evictLocation( + location: LocationRef, + requestOptions?: RequestOptions, + serviceOptions?: OpenCodeSharedServiceOptions, + ): Promise { + await this.withClient(serviceOptions, (client) => client.debug.location.evict({ + location: { + directory: location.directory, + ...(location.workspaceID ? { workspace: location.workspaceID } : {}), + }, + }, requestOptions)) + } + + async subscribe(requestOptions?: RequestOptions, serviceOptions?: OpenCodeSharedServiceOptions): Promise> { let connection: ServiceConnection | undefined try { - connection = await this.connect(ensureOptions) - const events = connection.client.event.subscribe(requestOptions) - return this.invalidateAfterStream(events, connection) + connection = await this.connect(serviceOptions) + return this.invalidateAfterStream(connection.client.event.subscribe(requestOptions), connection) } catch (error) { if (connection) this.invalidateConnection(connection) throw error } } - async evict( - location: LocationRef, - requestOptions?: RequestOptions, - ensureOptions?: OpenCodeEnsureOptions, - ): Promise { - requestOptions?.signal?.throwIfAborted() - if (ensureOptions) await this.connect(ensureOptions) - this.pendingEvictions.set(`${location.directory}\0${location.workspaceID ?? ""}`, location) - } - - shutdown(options: { timeoutMs?: number } = {}): Promise { - this.shutdownRequested = true - if (this.shutdownAttempt) return this.shutdownAttempt - const timeoutMs = Math.max(1, options.timeoutMs ?? 10_000) - const attempt = this.stopOwnedService(timeoutMs).finally(() => { - if (this.shutdownAttempt === attempt) this.shutdownAttempt = undefined - }) - this.shutdownAttempt = attempt - return attempt - } - - private async stopOwnedService(timeoutMs: number): Promise { - const deadlineAt = Date.now() + timeoutMs - const pendingLaunch = this.pendingLaunch - if (pendingLaunch) { - try { - await this.withDeadline(pendingLaunch, Math.max(1, deadlineAt - Date.now()), "OpenCode service launch reconciliation") - } catch (error) { - if (this.pendingLaunch === pendingLaunch) throw error - } - } - const lease = this.lease - if (!lease) return - await this.withLifecycleLock(lease, deadlineAt, async () => { - const ownMetadata = await this.readLease(lease.file) - if (!ownMetadata || ownMetadata.identity !== lease.identity) { - if (this.lease === lease) this.lease = undefined - throw new Error("OpenCode service lease identity changed") - } - const leaseDirectory = path.dirname(lease.file) - const initialPeers = await this.readPeerLeases(leaseDirectory, lease.file) - const inheritedProof = await this.deadPeerProof( - initialPeers, - this.ensureOptions?.file, - deadlineAt, - ownMetadata.launchSignature, - ) - await this.pruneLeaseArtifacts(leaseDirectory, lease.file, lease.staleLockMs, deadlineAt) - const peers = await this.readPeerLeases(leaseDirectory, lease.file) - const peerEntries = (await readdir(leaseDirectory)) - .filter((entry) => entry.endsWith(".json") && path.join(leaseDirectory, entry) !== lease.file) - if (peers.length !== peerEntries.length) { - throw new Error("An OpenCode service peer lease has invalid identity metadata; retaining lease") - } - const leasedProof = ownMetadata.service ?? inheritedProof - if (leasedProof && leasedProof.registrationFile !== this.ensureOptions?.file) { - throw new Error("OpenCode service lease proof references an unexpected registration; retaining lease") - } - let owned = this.owned - if (leasedProof) { - const launchSignature = ownMetadata.launchSignature - if (!launchSignature || leasedProof.launchSignature !== launchSignature) { - throw new Error("OpenCode service lease proof has a different launch configuration; retaining lease") - } - owned = { - stopOptions: { file: leasedProof.registrationFile }, - info: leasedProof.info, - endpoint: leasedProof.endpoint, - nativePid: leasedProof.nativePid === true, - processIdentity: leasedProof.processIdentity, - launchSignature, - } - this.owned = owned - } - if (!owned) { - await this.releaseLease(lease) - return - } - if (ownMetadata.state === "stopping") { - const stopped = await this.waitForOwnedStop(owned, deadlineAt) - if (!stopped) throw new Error("A previous OpenCode service stop has an uncertain outcome; retaining lease") - await this.finishOwnedStop(lease, owned) - return - } - const peerStates = await Promise.all(peers.map(async (peer) => ({ - peer, - state: await this.processOwnerState(peer.metadata, deadlineAt), - }))) - if (peerStates.some(({ state }) => state === "unknown")) { - throw new Error("An OpenCode service peer identity could not be verified; retaining lease") - } - const livePeers = peerStates.filter(({ state }) => state === "live").map(({ peer }) => peer) - if (livePeers.length) { - if (livePeers.some((peer) => peer.metadata.launchSignature !== owned.launchSignature)) { - throw new Error("OpenCode service peer launch configuration changed; retaining lease") - } - if (!livePeers.some((peer) => peer.metadata.service - && peer.metadata.service.launchSignature === owned.launchSignature - && this.sameInfo(peer.metadata.service.info, owned.info))) { - const elected = livePeers.sort((left, right) => left.metadata.identity.localeCompare(right.metadata.identity))[0] - await this.writeLease(elected.file, { - ...elected.metadata, - service: this.serviceProof(owned), - updatedAt: Date.now(), - }) - } - await this.releaseLease(lease) - if (this.owned && this.sameInfo(this.owned.info, owned.info)) this.owned = undefined - return - } - const current = await readSecureServiceInfo(owned.stopOptions.file) - // ponytail: uncertain registration or an unsupported stop API leaks the service; no PID is ever signaled. - if (!current || !this.sameInfo(current, owned.info)) { - throw new Error("OpenCode service ownership can no longer be proven; retaining lease") - } - if (!owned.processIdentity || !this.sameProcessIdentity( - await this.currentProcessIdentity(owned.info.pid, deadlineAt, owned.processIdentity.namespace), - owned.processIdentity, - )) { - throw new Error("OpenCode service process identity changed; retaining lease") - } - // ponytail: eviction is process-local and only safe after proving no peer and the exact daemon identity. - await this.flushPendingEvictions(owned) - const remaining = deadlineAt - Date.now() - if (remaining <= 0) throw new Error("OpenCode service shutdown deadline elapsed; retaining lease") - const proof = this.serviceProof(owned) - await this.writeLease(lease.file, { ...ownMetadata, service: proof, state: "stopping", updatedAt: Date.now() }) - const stopped = await this.withDeadline( - this.dependencies.requestStop - ? this.dependencies.requestStop(owned.info, owned.endpoint, remaining) - : this.requestStop(owned, remaining), - remaining, - "OpenCode service stop", - ) - if (!stopped) { - await this.writeLease(lease.file, { ...ownMetadata, service: proof, state: "active", updatedAt: Date.now() }) - throw new Error("OpenCode service rejected or failed the stop request; retaining lease") - } - if (!await this.waitForOwnedStop(owned, deadlineAt)) { - throw new Error("OpenCode service accepted stop but did not exit before the shutdown deadline; retaining lease") - } - await this.finishOwnedStop(lease, owned) - }) - } - - private async waitForOwnedStop( - owned: OwnedService, - deadlineAt: number, - ): Promise { - const remaining = deadlineAt - Date.now() - if (remaining <= 0) return false - return this.withDeadline( - this.dependencies.waitForStop - ? this.dependencies.waitForStop(owned.info, owned.endpoint, remaining) - : this.waitForStop(owned, remaining, deadlineAt), - remaining, - "OpenCode service stop completion", - ) - } - - private async finishOwnedStop( - lease: LeaseHandle, - owned: OwnedService, - ): Promise { - await this.releaseLease(lease) - if (this.owned && this.sameInfo(this.owned.info, owned.info)) this.owned = undefined + async shutdown(): Promise { + this.generation += 1 this.clear() + this.serviceOptions = undefined + this.serviceIdentity = undefined } - private connect(options?: OpenCodeEnsureOptions): Promise { - const selectedOptions = options ?? this.ensureOptions ?? {} - const launchSignature = this.launchSignature(selectedOptions) - if (!this.connected) { - if (this.pendingLaunch) { - if (this.ensureOptions && launchSignature !== this.launchSignature(this.ensureOptions)) { - return Promise.reject(new Error("OpenCode service launch configuration changed while startup is in progress")) - } - return this.withDeadline(this.pendingLaunch, selectedOptions.timeoutMs ?? 30_000, "OpenCode service ensure") - } - return this.connection ?? this.startConnection(selectedOptions) - } - if (launchSignature !== this.connected.launchSignature) { - return Promise.reject(new Error("OpenCode service launch configuration does not match the connected daemon")) + private connect(options?: OpenCodeSharedServiceOptions): Promise { + try { + this.pinServiceOptions(options) + } catch (error) { + return Promise.reject(error) } + if (!this.connected) return this.connection ?? this.startConnection() if (this.healthCheck) return this.healthCheck - const current = this.connection! - const { - contenderFile: _contenderFile, - leaseFile: _leaseFile, - lockDirectory: _lockDirectory, - timeoutMs: _timeoutMs, - staleLockMs: _staleLockMs, - nativePid: _nativePid, - wslDistro: _wslDistro, - environment: _environment, - launcherRecordsPid: _launcherRecordsPid, - windowsVerbatimArguments: _windowsVerbatimArguments, - onStart: _onStart, - command: _command, - ...discoverOptions - } = selectedOptions - const check = this.dependencies.discover(discoverOptions).then((endpoint) => { - if (endpoint && this.sameEndpoint(endpoint, this.connected!.endpoint)) return this.connected! - this.invalidate(current) - return this.startConnection(selectedOptions) + const current = this.connected + const check = this.lifecycle().discover().then((endpoint) => { + if (endpoint && this.sameEndpoint(endpoint, current.endpoint)) return current + this.invalidateConnection(current) + return endpoint ? this.createConnection(endpoint, this.generation) : this.startConnection() }, () => { - this.invalidate(current) - return this.startConnection(selectedOptions) + this.invalidateConnection(current) + return this.startConnection() }) const healthCheck = check.finally(() => { if (this.healthCheck === healthCheck) this.healthCheck = undefined @@ -395,97 +135,38 @@ export class OpenCodeSharedService { return healthCheck } - private startConnection(options: OpenCodeEnsureOptions): Promise { - this.ensureOptions = options - if (options.nativePid === false && !options.wslDistro) { - return Promise.reject(new Error("OpenCode service wrapper cannot prove the service PID")) - } - const { - contenderFile, - leaseFile, - lockDirectory, - timeoutMs = 30_000, - staleLockMs = DEFAULT_STALE_LOCK_MS, - environment, - launcherRecordsPid, - windowsVerbatimArguments, - ...ensureOptions - } = options - const launchSignature = this.launchSignature(options) - const onStart = ensureOptions.onStart - const pending = this.ensureLease( - leaseFile, - lockDirectory, - contenderFile, - ensureOptions.file, - timeoutMs, - staleLockMs, - launchSignature, - ).then(() => { - const launch = (this.dependencies.ensure - ? this.dependencies.ensure({ - ...ensureOptions, - onStart: (reason, previousVersion) => onStart?.(reason, previousVersion), - }).then((endpoint) => ({ endpoint, started: true })) - : this.ensureSafely(ensureOptions, environment, launcherRecordsPid, windowsVerbatimArguments, timeoutMs) - ).then(async ({ endpoint, started }) => { - assertLoopbackServiceUrl(endpoint.url) - const connection = { - endpoint, - stopOptions: { file: ensureOptions.file }, - client: this.dependencies.makeClient({ - baseUrl: endpoint.url, - headers: this.dependencies.headers(endpoint), - }), - launchSignature, - } - const info = await this.proveOwnership(connection.stopOptions.file, contenderFile, endpoint, started, timeoutMs) - if (info) { - const processIdentity = await this.requireServiceProcessIdentity(info.pid, timeoutMs, this.serviceNamespace()) - this.owned = { - stopOptions: connection.stopOptions, - info, - endpoint, - nativePid: options.nativePid !== false, - processIdentity, - launchSignature, - } - await this.updateLeaseService({ - info, - endpoint, - registrationFile: connection.stopOptions.file!, - nativePid: this.owned.nativePid, - processIdentity, - launchSignature, - }) - } - this.connected = connection - return connection - }) - const tracked = launch.finally(() => { - if (this.pendingLaunch === tracked) this.pendingLaunch = undefined - if (this.shutdownRequested) setImmediate(() => { void this.reconcileLateLaunch() }) - }) - this.pendingLaunch = tracked - return this.withDeadline(tracked, timeoutMs, "OpenCode service ensure") - }) - const connection = pending.catch((error) => { - this.invalidate(connection) + private startConnection(): Promise { + const generation = this.generation + const lifecycle = this.lifecycle() + const startup = lifecycle.discover() + .then((endpoint) => endpoint ?? lifecycle.ensure()) + .then((endpoint) => this.createConnection(endpoint, generation)) + const connection = startup.catch((error) => { + if (this.connection === connection) this.clear() throw error }) this.connection = connection return connection } - private async reconcileLateLaunch(): Promise { - await this.shutdownAttempt?.catch(() => undefined) - const lease = this.lease - if (!lease) return - await this.shutdown({ timeoutMs: lease.lifecycleTimeoutMs }).catch(() => undefined) + private createConnection(endpoint: Endpoint, generation: number): ServiceConnection { + assertLoopbackServiceUrl(endpoint.url) + const connection = { + endpoint, + client: this.dependencies.makeClient({ + baseUrl: endpoint.url, + headers: this.dependencies.headers(endpoint), + }), + } + if (generation === this.generation) { + this.connected = connection + this.connection = Promise.resolve(connection) + } + return connection } private async withClient( - options: OpenCodeEnsureOptions | undefined, + options: OpenCodeSharedServiceOptions | undefined, run: (client: OpenCodeClient) => Promise, ): Promise { let connection: ServiceConnection | undefined @@ -510,11 +191,6 @@ export class OpenCodeSharedService { if (this.connected === connection) this.clear() } - private invalidate(pending: Promise): void { - if (this.connection !== pending) return - this.clear() - } - private clear(): void { this.connection = undefined this.connected = undefined @@ -527,781 +203,25 @@ export class OpenCodeSharedService { && left.auth?.password === right.auth?.password } - private async proveOwnership( - file: string | undefined, - contenderFile: string | undefined, - endpoint: Endpoint, - started: boolean, - timeoutMs: number, - ): Promise { - if (!file) return undefined - const [info, contenders] = await Promise.all([ - readSecureServiceInfo(file), - contenderFile ? readFile(contenderFile, "utf8").catch(() => "") : "", - ]) - if (!info || !started) return undefined - if (info.url !== endpoint.url || info.password !== endpoint.auth?.password) return undefined - const lease = this.lease && await this.readLease(this.lease.file) - if (this.dependencies.ensure) { - if (!contenderFile || !contenders.split(/\r?\n/).includes(String(info.pid))) return undefined - } else if (!lease?.launch || lease.launch.nativePid !== (this.ensureOptions?.nativePid !== false)) { - return undefined + private lifecycle(): OpenCodeServiceLifecycle { + if (!this.serviceOptions) throw new Error("OpenCode service lifecycle has not been configured") + return this.serviceOptions.lifecycle + } + + private pinServiceOptions(options?: OpenCodeSharedServiceOptions): void { + const identity = options ? serviceIdentity(options) : this.serviceIdentity + if (!identity) throw new Error("OpenCode service lifecycle has not been configured") + if (this.serviceIdentity && this.serviceIdentity !== identity) { + throw new Error(`OpenCode service identity cannot change from ${this.serviceIdentity} to ${identity}`) } - if (!this.dependencies.ensure && !await this.registrationMatchesLaunch(file, endpoint)) return undefined - if (!await this.requireServiceProcessIdentity(info.pid, timeoutMs, this.serviceNamespace())) return undefined - return info - } - - private async ensureSafely( - options: EnsureOptions, - environment: NodeJS.ProcessEnv | undefined, - launcherRecordsPid: boolean | undefined, - windowsVerbatimArguments: boolean | undefined, - timeoutMs: number, - ): Promise<{ endpoint: Endpoint; started: boolean }> { - const discovered = await this.dependencies.discover(options) - if (discovered) return { - endpoint: discovered, - started: await this.registrationMatchesLaunch(options.file, discovered), - } - const lease = this.lease - if (!lease) throw new Error("OpenCode service launch requires a lifecycle lease") - const command = options.command - if (!command?.length) throw new Error("Missing OpenCode service command") - await this.quarantineStaleRegistration(options.file, lease) - let launch = await this.prepareLaunchIntent(lease, this.ensureOptions?.nativePid !== false) - options.onStart?.("missing") - const child = this.spawnService(command, environment, windowsVerbatimArguments) - child.stderr?.resume() - if (!child.pid) throw new Error("OpenCode service process did not expose a PID") - if (launch.nativePid && lease.contenderFile && !launcherRecordsPid) await appendFile(lease.contenderFile, `${child.pid}\n`) - launch = { ...launch, servicePid: launch.nativePid && !launcherRecordsPid ? child.pid : undefined } - await this.updateLaunchIntent(lease, launch) - const deadlineAt = Date.now() + timeoutMs - let lastError: Error | undefined - const childFailure = new Promise((_resolve, reject) => { - child.once("error", (error) => reject(new Error(`Failed to start OpenCode service: ${error.message}`))) - child.once("exit", (code, signal) => { - if (launcherRecordsPid && code === 0) return - lastError = new Error(`OpenCode service exited before registration (${signal ?? code ?? "unknown"})`) - }) - }) - child.unref() - while (Date.now() < deadlineAt) { - const endpoint = await Promise.race([ - this.dependencies.discover(options), - childFailure, - ]) - if (endpoint) return { endpoint, started: true } - if (lastError) throw lastError - await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(1, deadlineAt - Date.now())))) - } - throw new Error(`OpenCode service ensure timed out after ${timeoutMs}ms`) - } - - private spawnService( - command: ReadonlyArray, - environment: NodeJS.ProcessEnv | undefined, - windowsVerbatimArguments: boolean | undefined, - ): ChildProcess { - const [executable, ...args] = command - if (!executable) throw new Error("Missing OpenCode service command") - return spawn(executable, args, { - detached: true, - stdio: ["ignore", "ignore", "pipe"], - env: environment, - windowsHide: true, - windowsVerbatimArguments, - }) - } - - private async prepareLaunchIntent(lease: LeaseHandle, nativePid: boolean): Promise { - const launch = { identity: randomUUID(), createdAt: Date.now(), nativePid, contenderFile: lease.contenderFile } - await this.updateLaunchIntent(lease, launch) - return launch - } - - private async updateLaunchIntent(lease: LeaseHandle, launch: LaunchIntent): Promise { - await this.withLifecycleLock(lease, Date.now() + lease.lifecycleTimeoutMs, async () => { - const metadata = await this.readLease(lease.file) - if (!metadata || metadata.identity !== lease.identity) throw new Error("OpenCode service lease identity changed") - await this.writeLease(lease.file, { ...metadata, launch, updatedAt: Date.now() }) - }) - } - - private async requireServiceProcessIdentity( - pid: number, - timeoutMs: number, - namespace: ProcessNamespace, - ): Promise { - const identity = await (this.dependencies.getProcessIdentity ?? getProcessStartIdentity)(pid, Math.max(1, timeoutMs), namespace) - if (!identity) throw new Error("Unable to prove the OpenCode service process identity") - return identity - } - - private async registrationMatchesLaunch(file: string | undefined, endpoint: Endpoint): Promise { - const lease = this.lease && await this.readLease(this.lease.file) - const launch = lease?.launch - const info = await readSecureServiceInfo(file) - if (!launch || !info || info.url !== endpoint.url || info.password !== endpoint.auth?.password) return false - if (launch.servicePid !== undefined) return launch.servicePid === info.pid - if (launch.contenderFile) { - const contenders = await readFile(launch.contenderFile, "utf8").catch(() => "") - if (contenders.split(/\r?\n/).includes(String(info.pid))) return true - } - return false - } - - private async quarantineStaleRegistration(file: string | undefined, lease: LeaseHandle): Promise { - if (!file) return - const info = await readSecureServiceInfo(file) - if (!info) { - await this.quarantineRegistrationFile(file, lease.identity) - return - } - const metadata = await this.readLease(lease.file) - const proof = metadata?.service - const namespace = proof?.processIdentity?.namespace ?? this.serviceNamespace() - if (namespace.kind === "host" && !this.processIsAlive(info.pid)) { - await this.quarantineRegistrationFile(file, lease.identity, info) - return - } - if (namespace.kind === "wsl") { - const probe = await (this.dependencies.probeProcessIdentity ?? probeProcessStartIdentity)( - info.pid, - lease.lifecycleTimeoutMs, - namespace, - ) - if (probe.status === "missing") { - await this.quarantineRegistrationFile(file, lease.identity, info) - return - } - if (probe.status === "unknown") { - throw new Error("OpenCode service registration is unhealthy but its WSL PID identity cannot be verified") - } - } - if (proof && this.sameInfo(proof.info, info) && proof.processIdentity) { - const currentIdentity = await this.currentProcessIdentity( - info.pid, - Date.now() + lease.lifecycleTimeoutMs, - proof.processIdentity.namespace, - ) - if (currentIdentity && !this.sameProcessIdentity(currentIdentity, proof.processIdentity)) { - await this.quarantineRegistrationFile(file, lease.identity, info) - return - } - } - throw new Error("OpenCode service registration is unhealthy but its PID identity cannot be proven stale") - } - - private async quarantineRegistrationFile(file: string, identity: string, expected?: Info): Promise { - if (expected) { - const current = await readSecureServiceInfo(file) - if (!current || !this.sameInfo(current, expected)) return - } - const quarantine = `${file}.stale-${identity}-${randomUUID()}` - try { - await rename(file, quarantine) - await rm(quarantine, { force: true }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error - } - } - - private sameInfo(left: Info, right: Info): boolean { - return left.id === right.id - && left.version === right.version - && left.url === right.url - && left.pid === right.pid - && left.password === right.password - } - - private async ensureLease( - file: string | undefined, - lockDirectory: string | undefined, - contenderFile: string | undefined, - registrationFile: string | undefined, - timeoutMs: number, - staleLockMs: number, - launchSignature: string, - ): Promise { - if (!file || !lockDirectory) return - if (this.lease) { - const metadata = await this.readLease(this.lease.file) - if (!metadata || metadata.identity !== this.lease.identity || metadata.launchSignature !== launchSignature) { - throw new Error("OpenCode service launch configuration does not match the active lifecycle lease") - } - return - } - const identity = randomUUID() - const lease = { - file, - lockDirectory, - contenderFile, - identity, - staleLockMs: Math.max(1, staleLockMs), - lifecycleTimeoutMs: timeoutMs, - } - const deadlineAt = Date.now() + timeoutMs - await this.withLifecycleLock(lease, deadlineAt, async () => { - const peerLeases = await this.readPeerLeases(path.dirname(file)) - const peerStates = await Promise.all(peerLeases.map((peer) => this.processOwnerState(peer.metadata, deadlineAt))) - if (peerLeases.some((peer, index) => peerStates[index] === "live" && peer.metadata.launchSignature !== launchSignature)) { - throw new Error("OpenCode service launch configuration does not match the shared daemon") - } - if (await this.hasConflictingStaleService( - peerLeases, - peerStates, - registrationFile, - launchSignature, - deadlineAt, - )) { - throw new Error("OpenCode service launch configuration does not match the discovered daemon") - } - const inheritedProof = await this.deadPeerProof( - peerLeases, - registrationFile, - deadlineAt, - launchSignature, - ) - const inheritedLaunch = inheritedProof ? undefined : await this.deadPeerLaunch( - peerLeases, - deadlineAt, - launchSignature, - ) - await this.pruneLeaseArtifacts(path.dirname(file), undefined, lease.staleLockMs, deadlineAt) - const now = Date.now() - const processIdentity = await this.requireCurrentProcessIdentity(deadlineAt) - await this.writeLease(file, { - version: 1, - identity, - pid: process.pid, - processIdentity, - createdAt: now, - updatedAt: now, - state: "active", - contenderFile, - launch: inheritedLaunch, - service: inheritedProof, - launchSignature, - }, true) - this.lease = lease - }) - } - - private async updateLeaseService(service: ServiceProof): Promise { - const lease = this.lease - if (!lease) return - await this.withLifecycleLock(lease, Date.now() + lease.lifecycleTimeoutMs, async () => { - const metadata = await this.readLease(lease.file) - if (!metadata || metadata.identity !== lease.identity) throw new Error("OpenCode service lease identity changed") - await this.writeLease(lease.file, { ...metadata, service, updatedAt: Date.now() }) - }) - } - - private async releaseLease(lease: LeaseHandle): Promise { - const metadata = await this.readLease(lease.file) - if (metadata?.identity === lease.identity) await rm(lease.file, { force: true }) - if (lease.contenderFile) await rm(lease.contenderFile, { force: true }) - if (this.lease === lease) this.lease = undefined - } - - private async readPeerLeases(directory: string, ownFile?: string): Promise> { - const entries = (await readdir(directory)).filter((entry) => entry.endsWith(".json")) - const peers = await Promise.all(entries.map(async (entry) => { - const file = path.join(directory, entry) - if (file === ownFile) return undefined - const metadata = await this.readLease(file) - return metadata ? { file, metadata } : undefined - })) - return peers.filter((peer): peer is { file: string; metadata: LeaseMetadata } => Boolean(peer)) - } - - private async pruneLeaseArtifacts( - directory: string, - ownFile: string | undefined, - staleMs: number, - deadlineAt: number, - ): Promise { - for (const entry of await readdir(directory)) { - const file = path.join(directory, entry) - if (file === ownFile || (!entry.endsWith(".json") && !entry.endsWith(".tmp"))) continue - const peer = entry.endsWith(".json") ? await this.readLease(file) : undefined - if (peer) { - const state = await this.processOwnerState(peer, deadlineAt) - if (state === "live" || (state === "unknown" && Date.now() - peer.updatedAt < staleMs)) continue - const current = await this.readLease(file) - if (!current || current.identity !== peer.identity) continue - const currentState = await this.processOwnerState(current, deadlineAt) - if (currentState === "live" || (currentState === "unknown" && Date.now() - current.updatedAt < staleMs)) continue - await rm(file, { force: true }) - continue - } - await this.pruneInvalidArtifact(file, staleMs) - } - } - - private async pruneInvalidArtifact(file: string, staleMs: number): Promise { - try { - const before = await lstat(file) - if (!before.isFile() || before.isSymbolicLink() || Date.now() - before.mtimeMs < staleMs) return - const after = await lstat(file) - if (!this.sameFileIdentity(before, after) || Date.now() - after.mtimeMs < staleMs) return - await rm(file, { force: true }) - } catch {} - } - - private async deadPeerProof( - peers: Array<{ file: string; metadata: LeaseMetadata }>, - registrationFile: string | undefined, - deadlineAt: number, - launchSignature: string | undefined, - ): Promise { - if (!registrationFile || !launchSignature) return undefined - const states = await Promise.all(peers.map((peer) => this.processOwnerState(peer.metadata, deadlineAt))) - const proofs = peers - .filter((peer, index) => states[index] === "stale" && peer.metadata.launchSignature === launchSignature) - .map((peer) => peer.metadata.service) - .filter((proof): proof is ServiceProof => ( - proof?.registrationFile === registrationFile && proof.launchSignature === launchSignature - )) - const first = proofs[0] - if (!first) return undefined - return proofs.every((proof) => this.sameInfo(proof.info, first.info) - && this.sameEndpoint(proof.endpoint, first.endpoint) - && proof.nativePid === first.nativePid - && (proof.processIdentity === undefined - ? first.processIdentity === undefined - : first.processIdentity !== undefined && this.sameProcessIdentity(proof.processIdentity, first.processIdentity))) - ? first - : undefined - } - - private async deadPeerLaunch( - peers: Array<{ file: string; metadata: LeaseMetadata }>, - deadlineAt: number, - launchSignature: string, - ): Promise { - for (const peer of peers) { - if ( - peer.metadata.launchSignature !== launchSignature - || !peer.metadata.launch - || await this.processOwnerState(peer.metadata, deadlineAt) !== "stale" - ) continue - return peer.metadata.launch - } - return undefined - } - - private async hasConflictingStaleService( - peers: Array<{ file: string; metadata: LeaseMetadata }>, - states: ProcessOwnerState[], - registrationFile: string | undefined, - launchSignature: string, - deadlineAt: number, - ): Promise { - if (!registrationFile) return false - const current = await readSecureServiceInfo(registrationFile) - if (!current) return false - for (let index = 0; index < peers.length; index++) { - const peer = peers[index] - const proof = peer?.metadata.service - if ( - states[index] !== "stale" - || !proof - || (peer?.metadata.launchSignature === launchSignature && proof.launchSignature === launchSignature) - || proof.registrationFile !== registrationFile - || !this.sameInfo(proof.info, current) - ) continue - if (!proof.processIdentity) return true - if (proof.processIdentity.namespace.kind === "host" && !this.processIsAlive(proof.info.pid)) continue - if (proof.processIdentity.namespace.kind === "wsl") { - const timeoutMs = deadlineAt - Date.now() - if (timeoutMs <= 0) return true - const probe = await (this.dependencies.probeProcessIdentity ?? probeProcessStartIdentity)( - proof.info.pid, - timeoutMs, - proof.processIdentity.namespace, - ) - if (probe.status === "missing") continue - if (probe.status !== "found" || this.sameProcessIdentity(probe.identity, proof.processIdentity)) return true - continue - } - const identity = await this.currentProcessIdentity( - proof.info.pid, - deadlineAt, - proof.processIdentity.namespace, - ) - if (!identity || this.sameProcessIdentity(identity, proof.processIdentity)) return true - } - return false - } - - private async processOwnerState( - owner: Pick, - deadlineAt: number, - ): Promise { - const timeoutMs = deadlineAt - Date.now() - if (timeoutMs <= 0) return "unknown" - if (owner.processIdentity.namespace.kind === "wsl") { - const probe = await (this.dependencies.probeProcessIdentity ?? probeProcessStartIdentity)( - owner.pid, - timeoutMs, - owner.processIdentity.namespace, - ) - if (probe.status === "missing") return "stale" - return probe.status === "found" && this.sameProcessIdentity(probe.identity, owner.processIdentity) ? "live" : "unknown" - } - if (!this.processIsAlive(owner.pid)) return "stale" - const identity = await (this.dependencies.getProcessIdentity ?? getProcessStartIdentity)( - owner.pid, - timeoutMs, - owner.processIdentity.namespace, - ) - return identity ? this.sameProcessIdentity(identity, owner.processIdentity) ? "live" : "stale" : "unknown" - } - - private async requireCurrentProcessIdentity(deadlineAt: number): Promise { - const timeoutMs = deadlineAt - Date.now() - const identity = timeoutMs > 0 - ? await (this.dependencies.getProcessIdentity ?? getProcessStartIdentity)(process.pid, timeoutMs, { kind: "host" }) - : undefined - if (!identity) throw new Error("Unable to prove the OpenCode service lease process identity") - return identity - } - - private async currentProcessIdentity( - pid: number, - deadlineAt: number, - namespace: ProcessNamespace, - ): Promise { - const timeoutMs = deadlineAt - Date.now() - return timeoutMs > 0 - ? (this.dependencies.getProcessIdentity ?? getProcessStartIdentity)(pid, timeoutMs, namespace) - : undefined - } - - private processIsAlive(pid: number): boolean { - if (this.dependencies.isProcessAlive) return this.dependencies.isProcessAlive(pid) - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code === "EPERM" - } - } - - private async readLease(file: string): Promise { - try { - const before = await lstat(file) - if (!before.isFile() || before.isSymbolicLink()) return undefined - const handle = await open(file, "r") - try { - const stat = await handle.stat() - if (!stat.isFile() || (process.platform !== "win32" && (before.dev !== stat.dev || before.ino !== stat.ino))) return undefined - const value: unknown = JSON.parse(await handle.readFile("utf8")) - if (!this.isLeaseMetadata(value)) return undefined - return value - } finally { - await handle.close() - } - } catch { - return undefined - } - } - - private async writeLease(file: string, metadata: LeaseMetadata, exclusive = false): Promise { - if (exclusive) { - const handle = await open(file, "wx", 0o600) - try { await handle.writeFile(JSON.stringify(metadata)) } finally { await handle.close() } - return - } - const temporary = `${file}.${metadata.identity}.tmp` - const handle = await open(temporary, "wx", 0o600) - try { await handle.writeFile(JSON.stringify(metadata)) } finally { await handle.close() } - try { await rename(temporary, file) } catch (error) { - await rm(temporary, { force: true }) - throw error - } - } - - private isLeaseMetadata(value: unknown): value is LeaseMetadata { - if (!value || typeof value !== "object") return false - const lease = value as Partial - return lease.version === 1 - && typeof lease.identity === "string" && lease.identity.length > 0 - && typeof lease.pid === "number" && Number.isInteger(lease.pid) && lease.pid > 0 - && this.isProcessIdentity(lease.processIdentity) - && typeof lease.createdAt === "number" && Number.isFinite(lease.createdAt) - && typeof lease.updatedAt === "number" && Number.isFinite(lease.updatedAt) - && (lease.state === "active" || lease.state === "stopping") - && (lease.contenderFile === undefined || typeof lease.contenderFile === "string") - && (lease.launch === undefined || this.isLaunchIntent(lease.launch)) - && (lease.service === undefined || this.isServiceProof(lease.service)) - && (lease.launchSignature === undefined || typeof lease.launchSignature === "string") - } - - private isLaunchIntent(value: unknown): value is LaunchIntent { - if (!value || typeof value !== "object") return false - const launch = value as Partial - return typeof launch.identity === "string" && launch.identity.length > 0 - && typeof launch.createdAt === "number" && Number.isFinite(launch.createdAt) - && typeof launch.nativePid === "boolean" - && (launch.contenderFile === undefined || typeof launch.contenderFile === "string") - && (launch.servicePid === undefined || Number.isInteger(launch.servicePid) && launch.servicePid > 0) - } - - private isServiceProof(value: unknown): value is ServiceProof { - if (!value || typeof value !== "object") return false - const proof = value as Partial - if (typeof proof.registrationFile !== "string") return false - if (!proof.endpoint || !proof.info || typeof proof.endpoint.url !== "string") return false - if (!proof.info.id || !Number.isInteger(proof.info.pid) || proof.info.pid <= 0) return false - if (proof.nativePid !== undefined && typeof proof.nativePid !== "boolean") return false - if (proof.processIdentity !== undefined && !this.isProcessIdentity(proof.processIdentity)) return false - if (proof.launchSignature !== undefined && typeof proof.launchSignature !== "string") return false - if (proof.info.url !== proof.endpoint.url || proof.info.password !== proof.endpoint.auth?.password) return false - try { assertLoopbackServiceUrl(proof.endpoint.url) } catch { return false } - return true - } - - private serviceProof(owned: OwnedService): ServiceProof { - if (!owned.stopOptions.file) throw new Error("OpenCode service registration file is missing") - return { - info: owned.info, - endpoint: owned.endpoint, - registrationFile: owned.stopOptions.file, - nativePid: owned.nativePid, - processIdentity: owned.processIdentity, - launchSignature: owned.launchSignature, - } - } - - private async withLifecycleLock(lease: Pick, deadlineAt: number, run: () => Promise): Promise { - const owner: LockOwner = { - version: 1, - identity: lease.identity, - pid: process.pid, - processIdentity: await this.requireCurrentProcessIdentity(deadlineAt), - createdAt: Date.now(), - } - const ownerFile = path.join(lease.lockDirectory, LOCK_OWNER_FILE) - while (true) { - try { - await mkdir(lease.lockDirectory, { mode: 0o700 }) - await this.writeLockOwner(ownerFile, owner) - break - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error - await this.reclaimStaleLock(lease.lockDirectory, ownerFile, lease.staleLockMs, deadlineAt) - if (Date.now() >= deadlineAt) throw new Error("Timed out waiting for the OpenCode service lifecycle lock") - await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadlineAt - Date.now())))) - } - } - try { - return await run() - } finally { - const current = await this.readLockOwner(ownerFile) - if (current?.identity === owner.identity) await rm(lease.lockDirectory, { recursive: true, force: true }) - } - } - - private async reclaimStaleLock(directory: string, ownerFile: string, staleLockMs: number, deadlineAt: number): Promise { - let before: Stats - try { before = await lstat(directory) } catch { return } - if (!before.isDirectory() || before.isSymbolicLink()) return - const ownerBefore = await this.regularFileStat(ownerFile) - const owner = await this.readLockOwner(ownerFile) - if (owner && await this.processOwnerState(owner, deadlineAt) === "live") return - const now = Date.now() - const ownerCreatedAt = owner && owner.createdAt <= now ? owner.createdAt : 0 - if (now - Math.max(before.mtimeMs, ownerBefore?.mtimeMs ?? 0, ownerCreatedAt) < staleLockMs) return - const current = await this.readLockOwner(ownerFile) - if (owner) { - if (!current || current.identity !== owner.identity || await this.processOwnerState(current, deadlineAt) === "live") return - } else if (current) return - let after: Stats - try { after = await lstat(directory) } catch { return } - const ownerAfter = await this.regularFileStat(ownerFile) - if (!this.sameFileIdentity(before, after)) return - if (ownerBefore && (!ownerAfter || !this.sameFileIdentity(ownerBefore, ownerAfter))) return - if (!ownerBefore && ownerAfter) return - if (Date.now() - Math.max(after.mtimeMs, ownerAfter?.mtimeMs ?? 0) < staleLockMs) return - const staleDirectory = `${directory}.stale-${owner?.identity ?? "unknown"}-${randomUUID()}` - try { await rename(directory, staleDirectory) } catch { return } - await rm(staleDirectory, { recursive: true, force: true }) - } - - private sameFileIdentity(left: Stats, right: Stats): boolean { - return left.dev === right.dev - && left.ino === right.ino - && left.size === right.size - && left.mtimeMs === right.mtimeMs - && left.ctimeMs === right.ctimeMs - && left.birthtimeMs === right.birthtimeMs - } - - private async regularFileStat(file: string): Promise { - try { - const stat = await lstat(file) - return stat.isFile() && !stat.isSymbolicLink() ? stat : undefined - } catch { - return undefined - } - } - - private async writeLockOwner(file: string, owner: LockOwner): Promise { - const handle = await open(file, "wx", 0o600) - try { await handle.writeFile(JSON.stringify(owner)) } finally { await handle.close() } - } - - private async readLockOwner(file: string): Promise { - try { - const stat = await lstat(file) - if (!stat.isFile() || stat.isSymbolicLink()) return undefined - const handle = await open(file, "r") - try { - const value: unknown = JSON.parse(await handle.readFile("utf8")) - if (!value || typeof value !== "object") return undefined - const owner = value as Partial - if (owner.version !== 1 || typeof owner.identity !== "string" || !owner.identity) return undefined - if (typeof owner.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0) return undefined - if (!this.isProcessIdentity(owner.processIdentity)) return undefined - if (typeof owner.createdAt !== "number" || !Number.isFinite(owner.createdAt)) return undefined - return owner as LockOwner - } finally { - await handle.close() - } - } catch { - return undefined - } - } - - private async withDeadline(operation: Promise, timeoutMs: number, label: string): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - operation, - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs) - }), - ]) - } finally { - if (timer) clearTimeout(timer) - } - } - - private requestStop = async (owned: OwnedService, timeoutMs: number): Promise => { - if (owned.processIdentity?.namespace.kind === "wsl") { - if (!owned.info.id) return false - const client = this.dependencies.makeClient({ - baseUrl: owned.endpoint.url, - headers: this.dependencies.headers(owned.endpoint), - }) - return (await client.health.stop( - { instanceID: owned.info.id }, - { signal: AbortSignal.timeout(timeoutMs) }, - )).accepted - } - await (this.dependencies.stop ?? Service.stop)(owned.stopOptions) - return true - } - - private waitForStop = async ( - owned: OwnedService, - timeoutMs: number, - deadlineAt: number, - ): Promise => { - while (true) { - if (owned.processIdentity) { - if (owned.processIdentity.namespace.kind === "host" && !this.processIsAlive(owned.info.pid)) return true - if (owned.processIdentity.namespace.kind === "wsl") { - const remaining = deadlineAt - Date.now() - if (remaining <= 0) return false - const probe = await (this.dependencies.probeProcessIdentity ?? probeProcessStartIdentity)( - owned.info.pid, - remaining, - owned.processIdentity.namespace, - ) - if (probe.status === "missing") return true - if (probe.status === "found" && !this.sameProcessIdentity(probe.identity, owned.processIdentity)) return true - } else { - const identity = await this.currentProcessIdentity(owned.info.pid, deadlineAt, owned.processIdentity.namespace) - if (identity && !this.sameProcessIdentity(identity, owned.processIdentity)) return true - } - } - const remaining = deadlineAt - Date.now() - if (remaining <= 0) return false - const health = await fetch(new URL("/api/health", owned.info.url), { - headers: this.dependencies.headers(owned.endpoint), - signal: AbortSignal.timeout(Math.min(250, remaining)), - }).then(async (response) => { - const body = await response.json() as { healthy?: unknown; pid?: unknown } - return { unavailable: false, pid: response.ok && body?.healthy === true && Number.isInteger(body.pid) ? body.pid as number : undefined } - }).catch(() => ({ unavailable: true, pid: undefined })) - if (health.pid !== undefined && health.pid !== owned.info.pid) return true - if (health.unavailable && await this.registrationDisappeared(owned.stopOptions.file)) return true - await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadlineAt - Date.now())))) - } - } - - private async registrationDisappeared(file: string | undefined): Promise { - if (!file) return false - try { - await lstat(file) - } catch (error) { - return (error as NodeJS.ErrnoException).code === "ENOENT" - } - return false - } - - private serviceNamespace(): ProcessNamespace { - return this.ensureOptions?.wslDistro - ? { kind: "wsl", distro: this.ensureOptions.wslDistro } - : { kind: "host" } - } - - private isProcessIdentity(value: unknown): value is ProcessIdentity { - if (!value || typeof value !== "object") return false - const identity = value as Partial - const namespace = identity.namespace as Partial | undefined - return Number.isInteger(identity.pid) && (identity.pid ?? 0) > 0 - && typeof identity.start === "string" && identity.start.length > 0 - && (namespace?.kind === "host" - || namespace?.kind === "wsl" && typeof namespace.distro === "string" && namespace.distro.length > 0) - } - - private sameProcessIdentity(left: ProcessIdentity | undefined, right: ProcessIdentity): boolean { - if (!left) return false - return left.pid === right.pid - && left.start === right.start - && left.namespace.kind === right.namespace.kind - && (left.namespace.kind !== "wsl" - || right.namespace.kind === "wsl" && left.namespace.distro.toLowerCase() === right.namespace.distro.toLowerCase()) - } - - private launchSignature(options: OpenCodeEnsureOptions): string { - const environment = Object.entries(options.environment ?? {}).sort(([left], [right]) => left.localeCompare(right)) - return createHash("sha256").update(JSON.stringify({ - command: options.command ?? [], - environment, - version: options.version ?? null, - wslDistro: options.wslDistro ?? null, - windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, - })).digest("hex") - } - - private async flushPendingEvictions(owned: OwnedService): Promise { - if (!this.pendingEvictions.size) return - const client = this.connected?.client ?? this.dependencies.makeClient({ - baseUrl: owned.endpoint.url, - headers: this.dependencies.headers(owned.endpoint), - }) - for (const location of this.pendingEvictions.values()) { - await client.debug.location.evict({ - location: { directory: location.directory, workspace: location.workspaceID }, - }) - } - this.pendingEvictions.clear() + if (this.serviceIdentity) return + this.serviceIdentity = identity + this.serviceOptions = options } } + +function serviceIdentity(options: OpenCodeSharedServiceOptions): string { + const identity = options.identity.trim() + if (!identity) throw new Error("OpenCode service lifecycle identity must not be empty") + return `lifecycle:${identity}` +} diff --git a/packages/server/src/workspaces/process-identity.test.ts b/packages/server/src/workspaces/process-identity.test.ts deleted file mode 100644 index 380c31a5..00000000 --- a/packages/server/src/workspaces/process-identity.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import assert from "node:assert/strict" -import { test } from "node:test" - -import { probeProcessStartIdentity } from "./process-identity" - -test("probes a service PID inside its WSL distro instead of the coincidental Windows PID", async () => { - const calls: Array<{ command: string; args: string[] }> = [] - const probe = await probeProcessStartIdentity(4242, 100, { kind: "wsl", distro: "Ubuntu" }, async (command, args) => { - calls.push({ command, args }) - return { - code: 0, - stdout: "4242 (opencode) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 98765 20\nlinux-boot-id\n", - } - }) - - assert.equal(calls[0]?.command, "wsl.exe") - assert.deepEqual(calls[0]?.args.slice(0, 2), ["--distribution", "Ubuntu"]) - assert.deepEqual(probe, { - status: "found", - identity: { namespace: { kind: "wsl", distro: "Ubuntu" }, pid: 4242, start: "linux-boot-id:98765" }, - }) -}) - -test("distinguishes a missing WSL PID from an unverified probe", async () => { - const missing = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({ - code: 3, - stdout: "", - })) - const unknown = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({ - code: null, - stdout: "", - })) - - assert.deepEqual(missing, { status: "missing" }) - assert.deepEqual(unknown, { status: "unknown" }) -}) diff --git a/packages/server/src/workspaces/process-identity.ts b/packages/server/src/workspaces/process-identity.ts deleted file mode 100644 index c8764729..00000000 --- a/packages/server/src/workspaces/process-identity.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { execFile } from "node:child_process" -import { readFile } from "node:fs/promises" - -export type ProcessNamespace = { kind: "host" } | { kind: "wsl"; distro: string } - -export interface ProcessIdentity { - namespace: ProcessNamespace - pid: number - start: string -} - -export type ProcessIdentityProbe = - | { status: "found"; identity: ProcessIdentity } - | { status: "missing" } - | { status: "unknown" } - -export type ProcessIdentityRunner = ( - command: string, - args: string[], - timeoutMs: number, -) => Promise<{ code: number | null; stdout: string }> - -const runCommand: ProcessIdentityRunner = (command, args, timeoutMs) => new Promise((resolve) => { - execFile(command, args, { encoding: "utf8", windowsHide: true, timeout: timeoutMs }, (error, stdout) => { - resolve({ code: error ? typeof error.code === "number" ? error.code : null : 0, stdout }) - }) -}) - -export async function probeProcessStartIdentity( - pid: number, - timeoutMs: number, - namespace: ProcessNamespace = { kind: "host" }, - runner: ProcessIdentityRunner = runCommand, -): Promise { - if (!Number.isInteger(pid) || pid <= 0 || timeoutMs <= 0) return { status: "unknown" } - if (namespace.kind === "wsl") { - const result = await runner("wsl.exe", [ - "--distribution", namespace.distro, - "--exec", "sh", "-c", - 'test -r "/proc/$1/stat" || exit 3; cat "/proc/$1/stat"; cat /proc/sys/kernel/random/boot_id', - "codenomad-process-probe", String(pid), - ], timeoutMs).catch(() => ({ code: null, stdout: "" })) - if (result.code === 3) return { status: "missing" } - const [stat, bootId] = result.stdout.trim().split(/\r?\n/) - const start = result.code === 0 && stat && bootId ? linuxStart(stat, bootId) : undefined - return start - ? { status: "found", identity: { namespace, pid, start } } - : { status: "unknown" } - } - - try { - if (process.platform === "linux") { - const signal = AbortSignal.timeout(timeoutMs) - const stat = await readFile(`/proc/${pid}/stat`, { encoding: "utf8", signal }) - const bootId = (await readFile("/proc/sys/kernel/random/boot_id", { encoding: "utf8", signal })).trim() - const start = linuxStart(stat, bootId) - return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" } - } - if (process.platform === "darwin") { - return commandProbe(runner, "ps", ["-p", String(pid), "-o", "lstart="], namespace, pid, timeoutMs) - } - if (process.platform === "win32") { - return commandProbe(runner, "powershell.exe", [ - "-NoProfile", "-NonInteractive", "-Command", - `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop; if (!$p) { exit 3 }; $p.CreationDate.ToUniversalTime().Ticks`, - ], namespace, pid, timeoutMs) - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" } - } - return { status: "unknown" } -} - -export async function getProcessStartIdentity( - pid: number, - timeoutMs: number, - namespace: ProcessNamespace = { kind: "host" }, - runner?: ProcessIdentityRunner, -): Promise { - const probe = await probeProcessStartIdentity(pid, timeoutMs, namespace, runner) - return probe.status === "found" ? probe.identity : undefined -} - -function linuxStart(stat: string, bootId: string): string | undefined { - const commandEnd = stat.lastIndexOf(")") - const startTicks = commandEnd < 0 ? undefined : stat.slice(commandEnd + 1).trim().split(/\s+/)[19] - return startTicks && bootId ? `${bootId}:${startTicks}` : undefined -} - -async function commandProbe( - runner: ProcessIdentityRunner, - command: string, - args: string[], - namespace: ProcessNamespace, - pid: number, - timeoutMs: number, -): Promise { - const result = await runner(command, args, timeoutMs).catch(() => ({ code: null, stdout: "" })) - if (result.code === 3) return { status: "missing" } - const start = result.code === 0 ? result.stdout.trim() : "" - return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" } -} diff --git a/packages/server/src/workspaces/service-state.ts b/packages/server/src/workspaces/service-state.ts index 3da9c7bc..5d9b1333 100644 --- a/packages/server/src/workspaces/service-state.ts +++ b/packages/server/src/workspaces/service-state.ts @@ -1,48 +1,4 @@ -import { constants, closeSync, chmodSync, lstatSync, mkdirSync, openSync, readFileSync } from "node:fs" -import { open } from "node:fs/promises" import { isIP } from "node:net" -import os from "node:os" -import path from "node:path" -import type { Info } from "@opencode-ai/client/service" - -const CODENOMAD_HOME = path.join(os.homedir(), ".codenomad") -const CODENOMAD_STATE = path.join(CODENOMAD_HOME, "state") -export const SERVICE_STATE_ROOT = path.join(CODENOMAD_STATE, "opencode-v2") -export const SERVICE_REGISTRATION_FILE = path.join(SERVICE_STATE_ROOT, "opencode", "service.json") -export const SERVICE_LEASE_DIRECTORY = path.join(SERVICE_STATE_ROOT, "leases") -export const SERVICE_STOP_LOCK = path.join(SERVICE_STATE_ROOT, "stop.lock") - -const preparedFiles = new Set() - -export function prepareServiceState(contenderFile: string): void { - ensurePrivateDirectory(CODENOMAD_HOME) - ensurePrivateDirectory(CODENOMAD_STATE) - ensurePrivateDirectory(SERVICE_STATE_ROOT) - ensurePrivateDirectory(path.dirname(SERVICE_REGISTRATION_FILE)) - ensurePrivateDirectory(SERVICE_LEASE_DIRECTORY) - validateRegistrationFile(SERVICE_REGISTRATION_FILE) - if (preparedFiles.has(contenderFile)) return - closeSync(openSync(contenderFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)) - preparedFiles.add(contenderFile) -} - -export async function readSecureServiceInfo(file: string | undefined): Promise { - if (!file) return undefined - let handle - try { - const before = lstatSync(file) - if (!before.isFile() || before.isSymbolicLink()) return undefined - const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW - handle = await open(file, flags) - const after = await handle.stat() - if (process.platform !== "win32" && (before.dev !== after.dev || before.ino !== after.ino)) return undefined - return parseInfo(await handle.readFile("utf8")) - } catch { - return undefined - } finally { - await handle?.close() - } -} export function assertLoopbackServiceUrl(value: string): URL { const url = new URL(value) @@ -57,50 +13,3 @@ export function assertLoopbackServiceUrl(value: string): URL { if (!loopback) throw new Error(`OpenCode service endpoint must be loopback: ${url.hostname}`) return url } - -function ensurePrivateDirectory(directory: string): void { - mkdirSync(directory, { recursive: true, mode: 0o700 }) - const stat = lstatSync(directory) - if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service state directory: ${directory}`) - if (process.platform === "win32") return - if (typeof process.getuid === "function" && stat.uid !== process.getuid()) { - throw new Error(`OpenCode service state directory is owned by another user: ${directory}`) - } - if ((stat.mode & 0o077) !== 0) chmodSync(directory, 0o700) -} - -function validateRegistrationFile(file: string): void { - let stat - try { - stat = lstatSync(file) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return - throw error - } - if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service registration file: ${file}`) - const text = readFileSync(file, "utf8") - let value: unknown - try { value = JSON.parse(text) } catch { return } - if (typeof value === "object" && value !== null && "url" in value && typeof value.url === "string") { - assertLoopbackServiceUrl(value.url) - } -} - -function parseInfo(text: string): Info | undefined { - let value: unknown - try { - value = JSON.parse(text) - } catch { - return undefined - } - if (typeof value !== "object" || value === null) return undefined - if (!("url" in value) || typeof value.url !== "string") return undefined - if (!("id" in value) || typeof value.id !== "string" || !value.id) return undefined - if (!("pid" in value) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0) return undefined - try { - assertLoopbackServiceUrl(value.url) - } catch { - return undefined - } - return value as Info -} diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index 112119b8..c6a915a1 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -7,7 +7,6 @@ export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i -const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", "OPENCODE_DB", "XDG_STATE_HOME"]) const WINDOWS_DIRECT_EXTENSIONS = new Set([".com", ".exe"]) const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" const WINDOWS_SHELL_NAMES = new Set([ @@ -38,21 +37,14 @@ export interface SpawnSpec { wsl?: { distro: string } } -export interface ServiceLaunchSpec { - command: string[] - env?: NodeJS.ProcessEnv - nativePid: boolean - wslDistro?: string - launcherRecordsPid?: boolean - windowsVerbatimArguments?: boolean -} +export type ServiceLaunchSpec = + | { kind: "host"; binary: string; platform: NodeJS.Platform } + | { kind: "wsl"; distro: string; binary: string } interface BuildSpawnSpecOptions { cwd?: string env?: NodeJS.ProcessEnv - propagateEnvKeys?: string[] platform?: NodeJS.Platform - contenderFile?: string } interface WslPath { @@ -196,42 +188,12 @@ export function resolveWslHostDirectory( export function buildServiceLaunchSpec( binaryPath: string, - args: string[], options: BuildSpawnSpecOptions = {}, ): ServiceLaunchSpec { - const spec = buildSpawnSpec(binaryPath, args, options) - const direct = spec.processKind === "posix" || spec.processKind === "windows-direct" - if (direct && options.contenderFile) { - const launcher = [ - 'const { spawn } = require("node:child_process")', - 'const { appendFileSync } = require("node:fs")', - 'const child = spawn(process.argv[1], JSON.parse(process.argv[2]), { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: process.argv[4] === "true" })', - 'child.once("error", (error) => { console.error(error); process.exitCode = 1 })', - 'if (child.pid) { appendFileSync(process.argv[3], `${child.pid}\\n`); child.unref() }', - ].join(";") - return { - command: [ - process.execPath, - "-e", - launcher, - spec.command, - JSON.stringify(spec.args), - options.contenderFile, - String(Boolean(spec.options.windowsVerbatimArguments)), - ], - env: spec.env, - nativePid: true, - launcherRecordsPid: true, - } - } - return { - command: [spec.command, ...spec.args], - env: spec.env, - nativePid: direct, - wslDistro: spec.wsl?.distro, - launcherRecordsPid: spec.processKind === "wsl" && Boolean(options.contenderFile), - windowsVerbatimArguments: spec.options.windowsVerbatimArguments, - } + const platform = options.platform ?? process.platform + const wslPath = platform === "win32" ? parseWslUncPath(binaryPath) : null + if (wslPath) return { kind: "wsl", distro: wslPath.distro, binary: wslPath.linuxPath } + return { kind: "host", binary: binaryPath, platform } } export function probeBinaryVersion(binaryPath: string): { @@ -290,7 +252,6 @@ export function probeBinaryVersion(binaryPath: string): { function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec { const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined - const env = buildWslEnvironment(options.env, options.propagateEnvKeys) if (options.cwd && !workingDirectory) { throw new Error( `Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`, @@ -298,14 +259,14 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn } const wslArgs = ["--distribution", wslPath.distro] - const shouldWrapWithShell = workingDirectory?.kind === "windows" || Boolean(options.contenderFile) + const shouldWrapWithShell = workingDirectory?.kind === "windows" if (!shouldWrapWithShell && workingDirectory?.kind === "linux") { wslArgs.push("--cd", workingDirectory.path) } if (shouldWrapWithShell) { - const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, Boolean(options.contenderFile)) + const launchScript = buildWslLaunchScript(workingDirectory) wslArgs.push( "--exec", "sh", @@ -313,9 +274,6 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn launchScript, "codenomad-wsl-launch", ) - if (options.contenderFile) { - wslArgs.push(options.contenderFile) - } if (workingDirectory) { wslArgs.push(workingDirectory.path) } @@ -332,7 +290,7 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn args: wslArgs, processKind: "wsl", options: {}, - env, + env: options.env, wsl: { distro: wslPath.distro }, } } @@ -348,8 +306,7 @@ function classifyWindowsCommand(binaryPath: string): SpawnProcessKind { return WINDOWS_DIRECT_EXTENSIONS.has(extension) ? "windows-direct" : "windows-wrapper" } - // Bare commands can resolve to npm/script shims, so keep them on the - // wrapper path. That path owns cleanup without requiring process discovery. + // Bare commands can resolve to npm/script shims, so classify them as wrappers. return "windows-wrapper" } @@ -397,14 +354,9 @@ function unquoteWindowsPathEntry(entry: string): string { : trimmed } -function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, recordContender: boolean): string { +function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined): string { const steps: string[] = [] - if (recordContender) { - steps.push('printf "%s\\n" "$$" >> "$(wslpath -au "$1")"') - steps.push("shift") - } - if (workingDirectory?.kind === "linux") { steps.push('cd "$1"') steps.push("shift") @@ -443,42 +395,3 @@ function normalizeWindowsPath(input: string): string | null { return null } - -function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys?: string[]): NodeJS.ProcessEnv | undefined { - if (!env) { - return env - } - - const next = { ...env } - const keysToPropagate = Array.from(new Set([ - ...(propagateEnvKeys ?? []), - ...WSL_PATH_ENV_KEYS, - ])).filter((key) => next[key] !== undefined) - if (keysToPropagate.length === 0) { - return next - } - - const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0) - const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry])) - - for (const key of keysToPropagate) { - const requiresPathTranslation = WSL_PATH_ENV_KEYS.has(key) && ( - key !== "OPENCODE_DB" || normalizeWindowsPath(next[key] ?? "") !== null - ) - const existingEntry = byName.get(key) - if (existingEntry) { - byName.set(key, setWslenvPathFlag(existingEntry, requiresPathTranslation)) - continue - } - byName.set(key, requiresPathTranslation ? `${key}/p` : key) - } - - next.WSLENV = Array.from(byName.values()).join(":") - return next -} - -function setWslenvPathFlag(entry: string, requiresPathTranslation: boolean): string { - const [name, rawFlags = ""] = entry.split("/") - const flags = rawFlags.replaceAll("p", "") + (requiresPathTranslation ? "p" : "") - return flags ? `${name}/${flags}` : name -} diff --git a/packages/server/src/workspaces/wsl-opencode-service.test.ts b/packages/server/src/workspaces/wsl-opencode-service.test.ts new file mode 100644 index 00000000..23b82000 --- /dev/null +++ b/packages/server/src/workspaces/wsl-opencode-service.test.ts @@ -0,0 +1,297 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + WslOpenCodeService, + type WslOpenCodeServiceDependencies, +} from "./wsl-opencode-service" + +type ExecCall = { + file: string + args: string[] + options: Parameters[2] +} + +const url = "http://127.0.0.1:4321" + +describe("WslOpenCodeService", () => { + it("discovers stopped and running services with exact CLI arguments and no shell", async () => { + const stopped = harness({ status: "stopped\n" }) + assert.equal(await stopped.service.discover(), undefined) + assert.equal(stopped.calls.length, 1) + assert.deepEqual(stopped.calls[0]?.args, [ + "--distribution", "Ubuntu", "--exec", "/home/dev/opencode2", "service", "status", + ]) + + const running = harness({ status: `${url}\n`, password: "secret\n" }) + assert.deepEqual(await running.service.discover(), { + url, + auth: { type: "basic", username: "opencode", password: "secret" }, + }) + assert.deepEqual(running.calls.map((call) => call.args.slice(4)), [ + ["service", "status"], + ["service", "get", "password"], + ]) + for (const call of running.calls) { + assert.equal(call.file, "wsl.exe") + assert.equal(call.options.shell, false) + assert.equal(call.options.windowsHide, true) + assert.equal(call.options.maxBuffer, 64 * 1024) + assert.ok(call.options.timeout > 0 && call.options.timeout <= 500) + assert.equal("cwd" in call.options, false) + } + }) + + it("starts through the Linux CLI, fetches the password, and authenticates Windows health", async () => { + let healthRequest: { url: string; authorization: string | null } | undefined + const test = harness({ start: `${url}\r\n`, password: "start-secret\r\n" }, { + fetch: async (input, init) => { + healthRequest = { + url: String(input), + authorization: new Headers(init?.headers).get("authorization"), + } + return Response.json({ healthy: true, version: "2.0.0", pid: 987654 }) + }, + }) + + const endpoint = await test.service.ensure() + + assert.deepEqual(test.calls.map((call) => call.args.slice(4)), [ + ["service", "start"], + ["service", "get", "password"], + ]) + assert.deepEqual(healthRequest, { + url: `${url}/api/health`, + authorization: `Basic ${Buffer.from("opencode:start-secret").toString("base64")}`, + }) + assert.deepEqual(endpoint, { + url, + auth: { type: "basic", username: "opencode", password: "start-secret" }, + }) + assert.equal("pid" in endpoint, false) + }) + + it("passes startup environment only through wsl --exec env for a missing service", async () => { + const test = harness( + { status: "stopped\n", start: `${url}\n`, password: "secret\n" }, + {}, + 500, + { PROVIDER_TOKEN: "value with spaces", NODE_EXTRA_CA_CERTS: "/ca.pem" }, + ) + + assert.equal(await test.service.discover(), undefined) + await test.service.ensure() + assert.deepEqual(test.calls.map((call) => call.args), [ + ["--distribution", "Ubuntu", "--exec", "/home/dev/opencode2", "service", "status"], + [ + "--distribution", "Ubuntu", "--exec", "env", + "NODE_EXTRA_CA_CERTS=/ca.pem", "PROVIDER_TOKEN=value with spaces", + "/home/dev/opencode2", "service", "start", + ], + ["--distribution", "Ubuntu", "--exec", "/home/dev/opencode2", "service", "get", "password"], + ]) + }) + + it("rejects malformed, multiline, non-loopback, and path-bearing service URLs", async () => { + for (const invalid of [ + "not-a-url\n", + `${url}\nhttp://127.0.0.1:4322\n`, + "http://192.0.2.1:4321\n", + `${url}/private\n`, + `http://user:pass@127.0.0.1:4321\n`, + `${url}?private=true\n`, + `${url}#private\n`, + ` ${url}\n`, + ]) { + await assert.rejects(harness({ status: invalid }).service.discover(), /invalid loopback URL|multiline|malformed/) + } + }) + + it("rejects empty and multiline passwords", async () => { + await assert.rejects(harness({ status: `${url}\n`, password: "\n" }).service.discover(), /empty password/) + await assert.rejects( + harness({ status: `${url}\n`, password: "first\nsecond\n" }).service.discover(), + /multiline password/, + ) + }) + + it("fails closed with actionable forwarding or health failures", async () => { + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => { throw new Error("ECONNREFUSED") }, + }).service.discover(), /Enable WSL localhost forwarding/) + + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => new Response(null, { status: 401 }), + }).service.discover(), /authentication failed.*401/) + + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => new Response(null, { status: 503 }), + }).service.discover(), /health check failed.*503/) + + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => Response.json({ healthy: false, pid: 12 }), + }).service.discover(), /not API-compatible/) + }) + + it("requires the complete compatible health shape", async () => { + for (const health of [ + { healthy: true, pid: 1 }, + { healthy: true, version: "", pid: 1 }, + { healthy: true, version: " ", pid: 1 }, + { healthy: true, version: "2.0.0", pid: 0 }, + { healthy: true, version: "2.0.0", pid: 1.5 }, + { healthy: true, version: "2.0.0", pid: Number.MAX_SAFE_INTEGER + 1 }, + ]) { + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => Response.json(health), + }).service.discover(), /not API-compatible/) + } + }) + + it("streams at most 64 KiB of health data and cancels an oversized body", async () => { + const valid = JSON.stringify({ healthy: true, version: "2.0.0", pid: 123 }).padEnd(64 * 1024, " ") + await harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => new Response(valid), + }).service.discover() + + let cancelled = false + const oversized = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1024)) + controller.enqueue(new Uint8Array([1])) + }, + cancel() { cancelled = true }, + }) + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => new Response(oversized), + }).service.discover(), /invalid health response/) + assert.equal(cancelled, true) + }) + + it("redacts every error field from password retrieval failures and timeouts", async () => { + const secret = "PASSWORD_SENTINEL_DO_NOT_LEAK" + const failure = Object.assign(new Error(secret), { + name: secret, + stack: secret, + code: secret, + stdout: secret, + stderr: secret, + cause: secret, + signal: secret, + cmd: secret, + }) + const failed = harness({}, { + execFile: async (_file, args) => { + if (args.at(-1) === "status") return { stdout: `${url}\n`, stderr: "" } + throw failure + }, + }) + await assert.rejects(failed.service.discover(), (error: Error) => { + assert.match(error.message, /password retrieval failed/) + assert.equal(error.message.includes(secret), false) + return true + }) + + const nonzero = harness({}, { + execFile: async (_file, args) => { + if (args.at(-1) === "status") return { stdout: `${url}\n`, stderr: "" } + throw Object.assign(new Error(secret), { code: 7, stdout: secret, stderr: secret }) + }, + }) + await assert.rejects(nonzero.service.discover(), (error: Error) => { + assert.match(error.message, /password retrieval failed \(exit code 7\)/) + assert.equal(error.message.includes(secret), false) + return true + }) + + const timeout = harness({}, { + execFile: async (_file, args) => args.at(-1) === "status" + ? { stdout: `${url}\n`, stderr: "" } + : new Promise(() => {}), + }, 15) + await assert.rejects(timeout.service.discover(), (error: Error) => { + assert.match(error.message, /password retrieval failed/) + assert.equal(error.message.includes(secret), false) + return true + }) + }) + + it("bounds shared deadlines and nonzero command errors", async () => { + const commandTimeouts: number[] = [] + const shared = harness({}, { + execFile: async (_file, args, options) => { + commandTimeouts.push(options.timeout) + if (args.at(-1) === "status") { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { stdout: `${url}\n`, stderr: "" } + } + return { stdout: "secret\n", stderr: "" } + }, + }, 100) + await shared.service.discover() + assert.ok((commandTimeouts[1] ?? 100) < (commandTimeouts[0] ?? 0)) + + const timeout = harness({}, { + execFile: async () => new Promise(() => {}), + }, 15) + await assert.rejects(timeout.service.discover(), /timed out after 15ms/) + + const output = "x".repeat(100_000) + const failure = Object.assign(new Error(output), { code: 7, stdout: output, stderr: output }) + const nonzero = harness({}, { + execFile: async () => { throw failure }, + }) + await assert.rejects(nonzero.service.discover(), (error: Error) => { + assert.match(error.message, /code 7/) + assert.ok(error.message.length < 1_200) + return true + }) + }) + + it("uses only status, start, and password service commands", async () => { + const test = harness({ status: `${url}\n`, start: `${url}\n`, password: "secret\n" }) + await test.service.discover() + await test.service.ensure() + + const tokens = test.calls.flatMap((call) => call.args) + for (const prohibited of [ + "stop", "restart", "serve", "--service", "--port", "--state", "--db", "pid", "process.kill", + ]) { + assert.equal(tokens.includes(prohibited), false, prohibited) + } + assert.deepEqual(test.calls.map((call) => call.args.slice(4)), [ + ["service", "status"], + ["service", "get", "password"], + ["service", "start"], + ["service", "get", "password"], + ]) + }) +}) + +function harness( + output: Partial>, + overrides: Partial = {}, + timeoutMs = 500, + startupEnvironment: NodeJS.ProcessEnv = {}, +) { + const calls: ExecCall[] = [] + const dependencies: WslOpenCodeServiceDependencies = { + execFile: async (file, args, options) => { + calls.push({ file, args, options }) + const operation = args.at(-1) + const key = operation === "status" || operation === "start" ? operation : "password" + return { stdout: output[key] ?? "", stderr: "" } + }, + fetch: async () => Response.json({ healthy: true, version: "2.0.0", pid: 123 }), + ...overrides, + } + return { + calls, + service: new WslOpenCodeService({ + distro: "Ubuntu", + binary: "/home/dev/opencode2", + startupEnvironment, + timeoutMs, + }, dependencies), + } +} diff --git a/packages/server/src/workspaces/wsl-opencode-service.ts b/packages/server/src/workspaces/wsl-opencode-service.ts new file mode 100644 index 00000000..58511dfc --- /dev/null +++ b/packages/server/src/workspaces/wsl-opencode-service.ts @@ -0,0 +1,54 @@ +import { + OpenCodeCliService, + type OpenCodeCliServiceDependencies, + type ServiceExecOptions, +} from "./opencode-cli-service" +import { daemonProcessEnvironment } from "./host-opencode-service" + +const DEFAULT_TIMEOUT_MS = 30_000 + +export type WslOpenCodeServiceDependencies = OpenCodeCliServiceDependencies + +export interface WslOpenCodeServiceOptions { + distro: string + binary: string + startupEnvironment?: NodeJS.ProcessEnv + timeoutMs?: number +} + +export class WslOpenCodeService extends OpenCodeCliService { + constructor( + options: WslOpenCodeServiceOptions, + dependencies: Partial = {}, + ) { + super({ + label: "WSL", + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + command: (args, start) => ({ + command: "wsl.exe", + args: [ + "--distribution", options.distro, "--exec", + ...(start && Object.keys(options.startupEnvironment ?? {}).length + ? ["env", ...environmentAssignments(options.startupEnvironment!)] + : []), + options.binary, + ...args, + ], + processKind: "wsl", + options: {}, + ...(start ? { env: daemonProcessEnvironment() } : {}), + wsl: { distro: options.distro }, + }), + unreachableMessage: (url) => `Cannot reach the WSL OpenCode service from Windows at ${url}. ` + + "Enable WSL localhost forwarding or configure a Windows opencode2 binary.", + }, dependencies) + } +} + +function environmentAssignments(environment: NodeJS.ProcessEnv): string[] { + return Object.entries(environment) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value ?? ""}`) +} + +export type { ServiceExecOptions } diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 26bb265c..770d6fc8 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -512,12 +512,14 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tempfile", "url", "uuid", @@ -4404,6 +4406,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.10.1" diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 94d56fcd..64762a11 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -13,6 +13,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" base64 = "0.22" +sha2 = "0.10" rustls = { version = "0.23", features = ["ring"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } regex = "1" @@ -30,6 +31,7 @@ tauri-plugin-global-shortcut = "2" url = "2" uuid = { version = "1", features = ["v4"] } tauri-plugin-notification = "2" +tauri-plugin-single-instance = "2" [target.'cfg(windows)'.dependencies] webview2-com = "=0.38.2" diff --git a/packages/tauri-app/src-tauri/build.rs b/packages/tauri-app/src-tauri/build.rs index 8c4baa63..c36e7728 100644 --- a/packages/tauri-app/src-tauri/build.rs +++ b/packages/tauri-app/src-tauri/build.rs @@ -1,5 +1,6 @@ fn main() { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"); + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"); let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set by Cargo"); let manifest_path = std::path::Path::new(&manifest_dir); let bundled_resources = std::path::Path::new(&out_dir) @@ -16,10 +17,47 @@ fn main() { std::fs::remove_dir_all(&bundled_resources).expect("clean bundled resources output"); } - println!("cargo:rerun-if-changed={}", manifest_path.join("tauri.conf.json").display()); - println!("cargo:rerun-if-changed={}", resources_root.join("node").display()); - println!("cargo:rerun-if-changed={}", resources_root.join("server").display()); - println!("cargo:rerun-if-changed={}", resources_root.join("ui-loading").display()); + println!( + "cargo:rerun-if-changed={}", + manifest_path.join("tauri.conf.json").display() + ); + println!( + "cargo:rerun-if-changed={}", + resources_root.join("node").display() + ); + println!( + "cargo:rerun-if-changed={}", + resources_root.join("server").display() + ); + println!( + "cargo:rerun-if-changed={}", + resources_root.join("ui-loading").display() + ); - tauri_build::build() + tauri_build::try_build(tauri_build::Attributes::new().app_manifest( + tauri_build::AppManifest::new().commands(&[ + "cli_get_status", + "cli_restart", + "wake_lock_start", + "wake_lock_stop", + "needs_local_certificate_install", + "open_remote_window", + "client_state_claim_access", + "client_state_load", + "client_state_save", + "client_state_commit_partitions", + "client_state_load_partition", + "client_state_set_restore_enabled", + "client_state_clear", + "client_state_renderer_flushed", + "client_state_navigation_flushed", + "desktop_launch_ready", + "desktop_launch_next_folder", + "desktop_launch_acknowledge_folder", + "install_stable_update", + "open_workspace_target", + "set_workspace_menu_enabled", + ]), + )) + .expect("build Tauri application and command ACL") } diff --git a/packages/tauri-app/src-tauri/capabilities/main-window.json b/packages/tauri-app/src-tauri/capabilities/main-window.json index e32bfa28..ff14871e 100644 --- a/packages/tauri-app/src-tauri/capabilities/main-window.json +++ b/packages/tauri-app/src-tauri/capabilities/main-window.json @@ -1,11 +1,11 @@ { "$schema": "https://schema.tauri.app/capabilities.json", "identifier": "main-window-native-dialogs", - "description": "Grant the main window access to required core features and native dialog commands.", + "description": "Grant local windows access to required core features and native dialog commands.", "remote": { - "urls": ["http://127.0.0.1:*", "http://localhost:*", "http://tauri.localhost/*", "https://tauri.localhost/*"] + "urls": ["http://127.0.0.1:*", "http://localhost:1420", "http://tauri.localhost/*", "https://tauri.localhost/*"] }, - "windows": ["main"], + "windows": ["local-*"], "permissions": [ "core:default", "core:menu:default", @@ -16,6 +16,27 @@ "notification:allow-request-permission", "notification:allow-notify", "notification:allow-show", - "core:webview:allow-set-webview-zoom" + "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-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" ] } 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 07e39db3..74aadde5 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 @@ -{"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-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-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-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":[]}},"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-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-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-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"]}}},"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 37b0f153..74d4d276 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 the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]},"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","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","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-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"]},"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 f7ab8174..fab3a590 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json @@ -344,6 +344,258 @@ "Identifier": { "description": "Permission identifier", "oneOf": [ + { + "description": "Enables the cli_get_status command without any pre-configured scope.", + "type": "string", + "const": "allow-cli-get-status", + "markdownDescription": "Enables the cli_get_status command without any pre-configured scope." + }, + { + "description": "Enables the cli_restart command without any pre-configured scope.", + "type": "string", + "const": "allow-cli-restart", + "markdownDescription": "Enables the cli_restart command without any pre-configured scope." + }, + { + "description": "Enables the client_state_claim_access command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-claim-access", + "markdownDescription": "Enables the client_state_claim_access command without any pre-configured scope." + }, + { + "description": "Enables the client_state_clear command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-clear", + "markdownDescription": "Enables the client_state_clear command without any pre-configured scope." + }, + { + "description": "Enables the client_state_commit_partitions command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-commit-partitions", + "markdownDescription": "Enables the client_state_commit_partitions command without any pre-configured scope." + }, + { + "description": "Enables the client_state_load command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-load", + "markdownDescription": "Enables the client_state_load command without any pre-configured scope." + }, + { + "description": "Enables the client_state_load_partition command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-load-partition", + "markdownDescription": "Enables the client_state_load_partition command without any pre-configured scope." + }, + { + "description": "Enables the client_state_navigation_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-navigation-flushed", + "markdownDescription": "Enables the client_state_navigation_flushed command without any pre-configured scope." + }, + { + "description": "Enables the client_state_renderer_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-renderer-flushed", + "markdownDescription": "Enables the client_state_renderer_flushed command without any pre-configured scope." + }, + { + "description": "Enables the client_state_save command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-save", + "markdownDescription": "Enables the client_state_save command without any pre-configured scope." + }, + { + "description": "Enables the client_state_set_restore_enabled command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-set-restore-enabled", + "markdownDescription": "Enables the client_state_set_restore_enabled command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_acknowledge_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-acknowledge-folder", + "markdownDescription": "Enables the desktop_launch_acknowledge_folder command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_next_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-next-folder", + "markdownDescription": "Enables the desktop_launch_next_folder command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_ready command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-ready", + "markdownDescription": "Enables the desktop_launch_ready command without any pre-configured scope." + }, + { + "description": "Enables the install_stable_update command without any pre-configured scope.", + "type": "string", + "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_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", + "const": "allow-open-workspace-target", + "markdownDescription": "Enables the open_workspace_target command without any pre-configured scope." + }, + { + "description": "Enables the set_workspace_menu_enabled command without any pre-configured scope.", + "type": "string", + "const": "allow-set-workspace-menu-enabled", + "markdownDescription": "Enables the set_workspace_menu_enabled command without any pre-configured scope." + }, + { + "description": "Enables the wake_lock_start command without any pre-configured scope.", + "type": "string", + "const": "allow-wake-lock-start", + "markdownDescription": "Enables the wake_lock_start command without any pre-configured scope." + }, + { + "description": "Enables the wake_lock_stop command without any pre-configured scope.", + "type": "string", + "const": "allow-wake-lock-stop", + "markdownDescription": "Enables the wake_lock_stop command without any pre-configured scope." + }, + { + "description": "Denies the cli_get_status command without any pre-configured scope.", + "type": "string", + "const": "deny-cli-get-status", + "markdownDescription": "Denies the cli_get_status command without any pre-configured scope." + }, + { + "description": "Denies the cli_restart command without any pre-configured scope.", + "type": "string", + "const": "deny-cli-restart", + "markdownDescription": "Denies the cli_restart command without any pre-configured scope." + }, + { + "description": "Denies the client_state_claim_access command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-claim-access", + "markdownDescription": "Denies the client_state_claim_access command without any pre-configured scope." + }, + { + "description": "Denies the client_state_clear command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-clear", + "markdownDescription": "Denies the client_state_clear command without any pre-configured scope." + }, + { + "description": "Denies the client_state_commit_partitions command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-commit-partitions", + "markdownDescription": "Denies the client_state_commit_partitions command without any pre-configured scope." + }, + { + "description": "Denies the client_state_load command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-load", + "markdownDescription": "Denies the client_state_load command without any pre-configured scope." + }, + { + "description": "Denies the client_state_load_partition command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-load-partition", + "markdownDescription": "Denies the client_state_load_partition command without any pre-configured scope." + }, + { + "description": "Denies the client_state_navigation_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-navigation-flushed", + "markdownDescription": "Denies the client_state_navigation_flushed command without any pre-configured scope." + }, + { + "description": "Denies the client_state_renderer_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-renderer-flushed", + "markdownDescription": "Denies the client_state_renderer_flushed command without any pre-configured scope." + }, + { + "description": "Denies the client_state_save command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-save", + "markdownDescription": "Denies the client_state_save command without any pre-configured scope." + }, + { + "description": "Denies the client_state_set_restore_enabled command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-set-restore-enabled", + "markdownDescription": "Denies the client_state_set_restore_enabled command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-acknowledge-folder", + "markdownDescription": "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_next_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-next-folder", + "markdownDescription": "Denies the desktop_launch_next_folder command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_ready command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-ready", + "markdownDescription": "Denies the desktop_launch_ready command without any pre-configured scope." + }, + { + "description": "Denies the install_stable_update command without any pre-configured scope.", + "type": "string", + "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_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", + "const": "deny-open-workspace-target", + "markdownDescription": "Denies the open_workspace_target command without any pre-configured scope." + }, + { + "description": "Denies the set_workspace_menu_enabled command without any pre-configured scope.", + "type": "string", + "const": "deny-set-workspace-menu-enabled", + "markdownDescription": "Denies the set_workspace_menu_enabled command without any pre-configured scope." + }, + { + "description": "Denies the wake_lock_start command without any pre-configured scope.", + "type": "string", + "const": "deny-wake-lock-start", + "markdownDescription": "Denies the wake_lock_start command without any pre-configured scope." + }, + { + "description": "Denies the wake_lock_stop command without any pre-configured scope.", + "type": "string", + "const": "deny-wake-lock-stop", + "markdownDescription": "Denies the wake_lock_stop command without any pre-configured scope." + }, { "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "type": "string", 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 f7ab8174..fab3a590 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json @@ -344,6 +344,258 @@ "Identifier": { "description": "Permission identifier", "oneOf": [ + { + "description": "Enables the cli_get_status command without any pre-configured scope.", + "type": "string", + "const": "allow-cli-get-status", + "markdownDescription": "Enables the cli_get_status command without any pre-configured scope." + }, + { + "description": "Enables the cli_restart command without any pre-configured scope.", + "type": "string", + "const": "allow-cli-restart", + "markdownDescription": "Enables the cli_restart command without any pre-configured scope." + }, + { + "description": "Enables the client_state_claim_access command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-claim-access", + "markdownDescription": "Enables the client_state_claim_access command without any pre-configured scope." + }, + { + "description": "Enables the client_state_clear command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-clear", + "markdownDescription": "Enables the client_state_clear command without any pre-configured scope." + }, + { + "description": "Enables the client_state_commit_partitions command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-commit-partitions", + "markdownDescription": "Enables the client_state_commit_partitions command without any pre-configured scope." + }, + { + "description": "Enables the client_state_load command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-load", + "markdownDescription": "Enables the client_state_load command without any pre-configured scope." + }, + { + "description": "Enables the client_state_load_partition command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-load-partition", + "markdownDescription": "Enables the client_state_load_partition command without any pre-configured scope." + }, + { + "description": "Enables the client_state_navigation_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-navigation-flushed", + "markdownDescription": "Enables the client_state_navigation_flushed command without any pre-configured scope." + }, + { + "description": "Enables the client_state_renderer_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-renderer-flushed", + "markdownDescription": "Enables the client_state_renderer_flushed command without any pre-configured scope." + }, + { + "description": "Enables the client_state_save command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-save", + "markdownDescription": "Enables the client_state_save command without any pre-configured scope." + }, + { + "description": "Enables the client_state_set_restore_enabled command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-set-restore-enabled", + "markdownDescription": "Enables the client_state_set_restore_enabled command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_acknowledge_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-acknowledge-folder", + "markdownDescription": "Enables the desktop_launch_acknowledge_folder command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_next_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-next-folder", + "markdownDescription": "Enables the desktop_launch_next_folder command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_ready command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-ready", + "markdownDescription": "Enables the desktop_launch_ready command without any pre-configured scope." + }, + { + "description": "Enables the install_stable_update command without any pre-configured scope.", + "type": "string", + "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_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", + "const": "allow-open-workspace-target", + "markdownDescription": "Enables the open_workspace_target command without any pre-configured scope." + }, + { + "description": "Enables the set_workspace_menu_enabled command without any pre-configured scope.", + "type": "string", + "const": "allow-set-workspace-menu-enabled", + "markdownDescription": "Enables the set_workspace_menu_enabled command without any pre-configured scope." + }, + { + "description": "Enables the wake_lock_start command without any pre-configured scope.", + "type": "string", + "const": "allow-wake-lock-start", + "markdownDescription": "Enables the wake_lock_start command without any pre-configured scope." + }, + { + "description": "Enables the wake_lock_stop command without any pre-configured scope.", + "type": "string", + "const": "allow-wake-lock-stop", + "markdownDescription": "Enables the wake_lock_stop command without any pre-configured scope." + }, + { + "description": "Denies the cli_get_status command without any pre-configured scope.", + "type": "string", + "const": "deny-cli-get-status", + "markdownDescription": "Denies the cli_get_status command without any pre-configured scope." + }, + { + "description": "Denies the cli_restart command without any pre-configured scope.", + "type": "string", + "const": "deny-cli-restart", + "markdownDescription": "Denies the cli_restart command without any pre-configured scope." + }, + { + "description": "Denies the client_state_claim_access command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-claim-access", + "markdownDescription": "Denies the client_state_claim_access command without any pre-configured scope." + }, + { + "description": "Denies the client_state_clear command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-clear", + "markdownDescription": "Denies the client_state_clear command without any pre-configured scope." + }, + { + "description": "Denies the client_state_commit_partitions command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-commit-partitions", + "markdownDescription": "Denies the client_state_commit_partitions command without any pre-configured scope." + }, + { + "description": "Denies the client_state_load command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-load", + "markdownDescription": "Denies the client_state_load command without any pre-configured scope." + }, + { + "description": "Denies the client_state_load_partition command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-load-partition", + "markdownDescription": "Denies the client_state_load_partition command without any pre-configured scope." + }, + { + "description": "Denies the client_state_navigation_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-navigation-flushed", + "markdownDescription": "Denies the client_state_navigation_flushed command without any pre-configured scope." + }, + { + "description": "Denies the client_state_renderer_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-renderer-flushed", + "markdownDescription": "Denies the client_state_renderer_flushed command without any pre-configured scope." + }, + { + "description": "Denies the client_state_save command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-save", + "markdownDescription": "Denies the client_state_save command without any pre-configured scope." + }, + { + "description": "Denies the client_state_set_restore_enabled command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-set-restore-enabled", + "markdownDescription": "Denies the client_state_set_restore_enabled command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-acknowledge-folder", + "markdownDescription": "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_next_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-next-folder", + "markdownDescription": "Denies the desktop_launch_next_folder command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_ready command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-ready", + "markdownDescription": "Denies the desktop_launch_ready command without any pre-configured scope." + }, + { + "description": "Denies the install_stable_update command without any pre-configured scope.", + "type": "string", + "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_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", + "const": "deny-open-workspace-target", + "markdownDescription": "Denies the open_workspace_target command without any pre-configured scope." + }, + { + "description": "Denies the set_workspace_menu_enabled command without any pre-configured scope.", + "type": "string", + "const": "deny-set-workspace-menu-enabled", + "markdownDescription": "Denies the set_workspace_menu_enabled command without any pre-configured scope." + }, + { + "description": "Denies the wake_lock_start command without any pre-configured scope.", + "type": "string", + "const": "deny-wake-lock-start", + "markdownDescription": "Denies the wake_lock_start command without any pre-configured scope." + }, + { + "description": "Denies the wake_lock_stop command without any pre-configured scope.", + "type": "string", + "const": "deny-wake-lock-stop", + "markdownDescription": "Denies the wake_lock_stop command without any pre-configured scope." + }, { "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "type": "string", diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/cli_get_status.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/cli_get_status.toml new file mode 100644 index 00000000..29e0e21f --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/cli_get_status.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-cli-get-status" +description = "Enables the cli_get_status command without any pre-configured scope." +commands.allow = ["cli_get_status"] + +[[permission]] +identifier = "deny-cli-get-status" +description = "Denies the cli_get_status command without any pre-configured scope." +commands.deny = ["cli_get_status"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/cli_restart.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/cli_restart.toml new file mode 100644 index 00000000..6ef8a293 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/cli_restart.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-cli-restart" +description = "Enables the cli_restart command without any pre-configured scope." +commands.allow = ["cli_restart"] + +[[permission]] +identifier = "deny-cli-restart" +description = "Denies the cli_restart command without any pre-configured scope." +commands.deny = ["cli_restart"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_claim_access.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_claim_access.toml new file mode 100644 index 00000000..44d26f34 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_claim_access.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +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"] + +[[permission]] +identifier = "deny-client-state-claim-access" +description = "Denies the client_state_claim_access command without any pre-configured scope." +commands.deny = ["client_state_claim_access"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_clear.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_clear.toml new file mode 100644 index 00000000..2306ce81 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_clear.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-clear" +description = "Enables the client_state_clear command without any pre-configured scope." +commands.allow = ["client_state_clear"] + +[[permission]] +identifier = "deny-client-state-clear" +description = "Denies the client_state_clear command without any pre-configured scope." +commands.deny = ["client_state_clear"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_commit_partitions.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_commit_partitions.toml new file mode 100644 index 00000000..9c1374e6 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_commit_partitions.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +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"] + +[[permission]] +identifier = "deny-client-state-commit-partitions" +description = "Denies the client_state_commit_partitions command without any pre-configured scope." +commands.deny = ["client_state_commit_partitions"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load.toml new file mode 100644 index 00000000..96453e78 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-load" +description = "Enables the client_state_load command without any pre-configured scope." +commands.allow = ["client_state_load"] + +[[permission]] +identifier = "deny-client-state-load" +description = "Denies the client_state_load command without any pre-configured scope." +commands.deny = ["client_state_load"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load_partition.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load_partition.toml new file mode 100644 index 00000000..29b98c4d --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load_partition.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +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"] + +[[permission]] +identifier = "deny-client-state-load-partition" +description = "Denies the client_state_load_partition command without any pre-configured scope." +commands.deny = ["client_state_load_partition"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_navigation_flushed.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_navigation_flushed.toml new file mode 100644 index 00000000..5b214253 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_navigation_flushed.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +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"] + +[[permission]] +identifier = "deny-client-state-navigation-flushed" +description = "Denies the client_state_navigation_flushed command without any pre-configured scope." +commands.deny = ["client_state_navigation_flushed"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_renderer_flushed.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_renderer_flushed.toml new file mode 100644 index 00000000..9cda03fa --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_renderer_flushed.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +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"] + +[[permission]] +identifier = "deny-client-state-renderer-flushed" +description = "Denies the client_state_renderer_flushed command without any pre-configured scope." +commands.deny = ["client_state_renderer_flushed"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_save.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_save.toml new file mode 100644 index 00000000..262d04c8 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_save.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-save" +description = "Enables the client_state_save command without any pre-configured scope." +commands.allow = ["client_state_save"] + +[[permission]] +identifier = "deny-client-state-save" +description = "Denies the client_state_save command without any pre-configured scope." +commands.deny = ["client_state_save"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_set_restore_enabled.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_set_restore_enabled.toml new file mode 100644 index 00000000..9bf09e82 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_set_restore_enabled.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +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"] + +[[permission]] +identifier = "deny-client-state-set-restore-enabled" +description = "Denies the client_state_set_restore_enabled command without any pre-configured scope." +commands.deny = ["client_state_set_restore_enabled"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_acknowledge_folder.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_acknowledge_folder.toml new file mode 100644 index 00000000..378a6ae5 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_acknowledge_folder.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +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"] + +[[permission]] +identifier = "deny-desktop-launch-acknowledge-folder" +description = "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope." +commands.deny = ["desktop_launch_acknowledge_folder"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_next_folder.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_next_folder.toml new file mode 100644 index 00000000..ae7cb50f --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_next_folder.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +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"] + +[[permission]] +identifier = "deny-desktop-launch-next-folder" +description = "Denies the desktop_launch_next_folder command without any pre-configured scope." +commands.deny = ["desktop_launch_next_folder"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_ready.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_ready.toml new file mode 100644 index 00000000..f42dd372 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_ready.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-desktop-launch-ready" +description = "Enables the desktop_launch_ready command without any pre-configured scope." +commands.allow = ["desktop_launch_ready"] + +[[permission]] +identifier = "deny-desktop-launch-ready" +description = "Denies the desktop_launch_ready command without any pre-configured scope." +commands.deny = ["desktop_launch_ready"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/install_stable_update.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/install_stable_update.toml new file mode 100644 index 00000000..6c20d103 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/install_stable_update.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-install-stable-update" +description = "Enables the install_stable_update command without any pre-configured scope." +commands.allow = ["install_stable_update"] + +[[permission]] +identifier = "deny-install-stable-update" +description = "Denies the install_stable_update command without any pre-configured scope." +commands.deny = ["install_stable_update"] 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 00000000..8870800b --- /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 00000000..1c8c6e88 --- /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/permissions/autogenerated/open_workspace_target.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/open_workspace_target.toml new file mode 100644 index 00000000..b79ca482 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/open_workspace_target.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-workspace-target" +description = "Enables the open_workspace_target command without any pre-configured scope." +commands.allow = ["open_workspace_target"] + +[[permission]] +identifier = "deny-open-workspace-target" +description = "Denies the open_workspace_target command without any pre-configured scope." +commands.deny = ["open_workspace_target"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/set_workspace_menu_enabled.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/set_workspace_menu_enabled.toml new file mode 100644 index 00000000..ed13d480 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/set_workspace_menu_enabled.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +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"] + +[[permission]] +identifier = "deny-set-workspace-menu-enabled" +description = "Denies the set_workspace_menu_enabled command without any pre-configured scope." +commands.deny = ["set_workspace_menu_enabled"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_start.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_start.toml new file mode 100644 index 00000000..1991b6cc --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_start.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-wake-lock-start" +description = "Enables the wake_lock_start command without any pre-configured scope." +commands.allow = ["wake_lock_start"] + +[[permission]] +identifier = "deny-wake-lock-start" +description = "Denies the wake_lock_start command without any pre-configured scope." +commands.deny = ["wake_lock_start"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_stop.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_stop.toml new file mode 100644 index 00000000..85e75671 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_stop.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-wake-lock-stop" +description = "Enables the wake_lock_stop command without any pre-configured scope." +commands.allow = ["wake_lock_stop"] + +[[permission]] +identifier = "deny-wake-lock-stop" +description = "Denies the wake_lock_stop command without any pre-configured scope." +commands.deny = ["wake_lock_stop"] diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 9fc96470..b18087f6 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -405,7 +405,11 @@ fn navigate_main(manager: &CliProcessManager, generation: u64, app: &AppHandle, if !manager.is_current_generation(generation) { return; } - if app.webview_windows().contains_key("main") { + if !app + .state::() + .records() + .is_empty() + { let final_url = augment_launch_url(url); let mut display = final_url.clone(); if let Some(hash_index) = display.find('#') { @@ -415,29 +419,41 @@ fn navigate_main(manager: &CliProcessManager, generation: u64, app: &AppHandle, if let Ok(parsed) = Url::parse(&final_url) { let current = manager.clone(); let navigate = manager.clone(); - crate::client_state::before_main_window_navigation_if( - app, - crate::client_state::NavigationKind::Cli, - Some(parsed.clone()), - move || current.is_current_generation(generation), - move |app| { - navigate - .with_current_generation(generation, || { - let window = app.get_webview_window("main").ok_or_else(|| { - "main window not found for CLI navigation".to_string() - })?; - window.navigate(parsed).map_err(|err| { - format!("failed to navigate main window to CLI URL: {err}") + app.state::() + .set_backend_target(Some(final_url)); + for record in app.state::().records() { + let current = current.clone(); + let navigate = navigate.clone(); + let parsed = parsed.clone(); + let label = record.label.clone(); + let target_label = label.clone(); + crate::client_state::before_window_navigation_if( + app, + label, + crate::client_state::NavigationKind::Cli, + Some(parsed.clone()), + move || current.is_current_generation(generation), + move |app| { + navigate + .with_current_generation(generation, || { + app.get_webview_window(&target_label) + .ok_or_else(|| { + "local window not found for CLI navigation".to_string() + })? + .navigate(parsed) + .map_err(|err| { + format!("failed to navigate local window to CLI URL: {err}") + }) }) - }) - .unwrap_or_else(|| Err("discarded stale CLI navigation".to_string())) - }, - ); + .unwrap_or_else(|| Err("discarded stale CLI navigation".to_string())) + }, + ); + } } else { log_line("failed to parse URL for navigation"); } } else { - log_line("main window not found for navigation"); + log_line("local window not found for navigation"); } } @@ -562,8 +578,10 @@ fn set_session_cookie( .same_site(tauri::webview::cookie::SameSite::Lax) .build(); - if let Some(win) = app.webview_windows().get("main") { - win.set_cookie(cookie)?; + for record in app.state::().records() { + if let Some(win) = app.get_webview_window(&record.label) { + win.set_cookie(cookie.clone())?; + } } Ok(()) @@ -877,8 +895,8 @@ impl CliProcessManager { status.error = Some(message.clone()); let snapshot = status.clone(); drop(status); - let _ = app.emit("cli:error", json!({"message": message})); - let _ = app.emit("cli:status", snapshot); + crate::local_windows::emit_all(app, "cli:error", json!({"message": message})); + crate::local_windows::emit_all(app, "cli:status", snapshot); }); } @@ -1104,8 +1122,8 @@ impl CliProcessManager { status.error = Some(message.clone()); let snapshot = status.clone(); drop(status); - let _ = app.emit("cli:error", json!({"message": message})); - let _ = app.emit("cli:status", snapshot); + crate::local_windows::emit_all(&app, "cli:error", json!({"message": message})); + crate::local_windows::emit_all(&app, "cli:status", snapshot); }); } @@ -1340,13 +1358,16 @@ impl CliProcessManager { } manager.with_current_generation(generation, || { let status = manager.status.lock().clone(); - let _ = app.emit("cli:ready", status.clone()); + crate::local_windows::emit_all(app, "cli:ready", status.clone()); Self::emit_status(app, &status); }); } fn emit_status(app: &AppHandle, status: &CliStatus) { - let _ = app.emit("cli:status", status.clone()); + if status.state != CliState::Ready { + crate::local_windows::show_loading_all(app); + } + crate::local_windows::emit_all(app, "cli:status", status.clone()); } } diff --git a/packages/tauri-app/src-tauri/src/client_state.rs b/packages/tauri-app/src-tauri/src/client_state.rs index 69d06726..048db00b 100644 --- a/packages/tauri-app/src-tauri/src/client_state.rs +++ b/packages/tauri-app/src-tauri/src/client_state.rs @@ -1,44 +1,48 @@ mod access; mod commands; mod cross_host; +mod envelope; mod navigation; +mod partitions; mod process; mod window; #[doc(hidden)] pub use commands::{ - __cmd__client_state_claim_access, __cmd__client_state_clear, __cmd__client_state_load, - __cmd__client_state_navigation_flushed, __cmd__client_state_renderer_flushed, - __cmd__client_state_save, __cmd__client_state_set_restore_enabled, + __cmd__client_state_claim_access, __cmd__client_state_clear, + __cmd__client_state_commit_partitions, __cmd__client_state_load, + __cmd__client_state_load_partition, __cmd__client_state_navigation_flushed, + __cmd__client_state_renderer_flushed, __cmd__client_state_save, + __cmd__client_state_set_restore_enabled, }; pub use commands::{ - client_state_claim_access, client_state_clear, client_state_load, - client_state_navigation_flushed, client_state_renderer_flushed, client_state_save, - client_state_set_restore_enabled, + client_state_claim_access, client_state_clear, client_state_commit_partitions, + client_state_load, client_state_load_partition, client_state_navigation_flushed, + client_state_renderer_flushed, client_state_save, client_state_set_restore_enabled, }; pub(crate) use navigation::{ - before_main_window_navigation, before_main_window_navigation_if, NavigationKind, + before_window_navigation, before_window_navigation_if, NavigationKind, }; pub use window::{ - capture_and_flush_main_window, main_window_zoom, set_main_window_zoom, setup_main_window, - DEFAULT_ZOOM_LEVEL, + capture_and_flush_all_windows, capture_and_flush_window, local_window_zoom, + set_local_window_zoom, setup_local_window, DEFAULT_ZOOM_LEVEL, }; +use envelope::PersistedClientState; use serde::Serialize; use serde_json::Value; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::AtomicU64; use std::sync::Mutex; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, Manager}; use url::Url; -use window::NativeWindowState; -const CLIENT_STATE_VERSION: u64 = 1; const CLIENT_STATE_FILENAME: &str = "client-state.json"; -const MAX_CLIENT_SNAPSHOT_BYTES: usize = 1024 * 1024; +const MAX_CLIENT_SNAPSHOT_BYTES: usize = envelope::MAX_SNAPSHOT_BYTES; const RENDERER_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Serialize)] @@ -50,36 +54,7 @@ pub(crate) struct RendererFlushRequest { #[derive(Default)] struct RendererFlush { request_lock: Mutex<()>, - next_generation: AtomicU64, - acknowledged_generation: AtomicU64, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct PersistedClientState { - version: u64, - restore_enabled: bool, - #[serde(skip_serializing_if = "Option::is_none")] - snapshot: Option, - #[serde(skip_serializing_if = "Option::is_none")] - window: Option, - #[serde(skip)] - unsupported_future_envelope: bool, - #[serde(skip)] - writes_enabled: bool, -} - -impl Default for PersistedClientState { - fn default() -> Self { - Self { - version: CLIENT_STATE_VERSION, - restore_enabled: true, - snapshot: None, - window: None, - unsupported_future_envelope: false, - writes_enabled: true, - } - } + windows: Mutex>, } #[derive(Debug, PartialEq, Serialize)] @@ -88,16 +63,18 @@ pub struct ClientStateLoadResult { is_primary: bool, restore_enabled: bool, snapshot: Value, + partition_protocol_version: Option, } pub struct ClientState { state_path: PathBuf, process: process::ProcessState, state: Mutex, - zoom_level: Mutex, + zoom_levels: Mutex>, write_lock: Mutex<()>, save_generation: AtomicU64, renderer_access: access::RendererAccess, + ephemeral_windows: Mutex>, renderer_flush: RendererFlush, write_state: StateWriter, } @@ -106,21 +83,45 @@ type StateWriter = std::sync::Arc bool) -> Result<(), String> + Send + Sync>; impl ClientState { - pub fn initialize(app: &AppHandle) -> Self { + pub(crate) fn stage_renderer_page_load( + &self, + window_id: &str, + url: &Url, + ) -> Result<(), String> { + self.renderer_access + .begin_navigation_for(window_id, Some(url)) + .map(|_| ()) + } + + pub fn initialize(app: &AppHandle, scoped_client_state_directory: Option<&Path>) -> Self { match app.path().app_data_dir() { Ok(app_data_dir) => { - match ( - cross_host::election_directory(), - cross_host::state_path(), - cross_host::legacy_state_path(), - ) { + let paths = scoped_client_state_directory + .map(|directory| { + ( + Ok(directory.join("election")), + Ok(directory.join(CLIENT_STATE_FILENAME)), + Ok(None), + ) + }) + .unwrap_or_else(|| { + ( + cross_host::election_directory(), + cross_host::state_path(), + cross_host::legacy_state_path().map(Some), + ) + }); + match paths { (Ok(election_dir), Ok(state_path), Ok(legacy_state_path)) => { - let legacy_electron = cross_host::legacy_electron_data_directory(); + let legacy_electron = scoped_client_state_directory + .is_none() + .then(cross_host::legacy_electron_data_directory) + .flatten(); Self::initialize_managed_at_with_election( &app_data_dir, &election_dir, &state_path, - Some(&legacy_state_path), + legacy_state_path.as_deref(), legacy_electron.as_deref(), ) } @@ -167,11 +168,10 @@ impl ClientState { } fn disabled(state_path: PathBuf) -> Self { - let state = PersistedClientState { - restore_enabled: false, - writes_enabled: false, - ..PersistedClientState::default() - }; + let mut state = PersistedClientState::default(); + let record = state.active_mut(); + record.restore_enabled = false; + record.writes_enabled = false; Self::new( state_path, process::ProcessState::disabled(), @@ -186,19 +186,28 @@ impl ClientState { state: PersistedClientState, write_state: StateWriter, ) -> Self { - let zoom_level = state - .restore_enabled - .then(|| state.window.as_ref().map(|window| window.zoom_factor)) - .flatten() - .unwrap_or(DEFAULT_ZOOM_LEVEL); + let zoom_levels = state + .window_order + .iter() + .map(|id| { + let record = state.windows.get(id).expect("validated window order"); + let zoom = record + .restore_enabled + .then(|| record.window.as_ref().map(|window| window.zoom_factor)) + .flatten() + .unwrap_or(DEFAULT_ZOOM_LEVEL); + (id.clone(), zoom) + }) + .collect(); Self { state_path, process, state: Mutex::new(state), - zoom_level: Mutex::new(zoom_level), + zoom_levels: Mutex::new(zoom_levels), write_lock: Mutex::new(()), save_generation: AtomicU64::new(0), renderer_access: access::RendererAccess::default(), + ephemeral_windows: Mutex::new(HashSet::new()), renderer_flush: RendererFlush::default(), write_state, } @@ -258,12 +267,7 @@ impl ClientState { } let state = if registration.is_primary() { if future_legacy { - PersistedClientState { - restore_enabled: false, - unsupported_future_envelope: true, - writes_enabled: false, - ..PersistedClientState::default() - } + envelope::unsupported() } else { read_client_state(state_path) } @@ -283,38 +287,180 @@ impl ClientState { self.process.is_primary() } - fn claim_renderer_access(&self, access_token: &str, renderer_url: &Url) -> Result<(), String> { + pub(crate) fn is_primary_process(&self) -> bool { + self.is_primary() + } + + pub(crate) fn window_ids(&self) -> Vec { + self.state + .lock() + .map(|state| state.window_order.clone()) + .unwrap_or_default() + } + + pub(crate) fn set_active_window(&self, window_id: &str) -> Result { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; - self.renderer_access.claim(access_token, renderer_url) + if !self.is_primary() { + return Ok(false); + } + let previous = { + let mut state = self.state.lock().map_err(|err| err.to_string())?; + if state.unsupported_future_envelope || state.active_window_id == window_id { + return Ok(!state.unsupported_future_envelope); + } + state.record(window_id)?; + let previous = state.active_window_id.clone(); + state.active_window_id = window_id.to_string(); + previous + }; + if let Err(error) = self.write_current_state() { + self.state + .lock() + .map_err(|err| err.to_string())? + .active_window_id = previous; + return Err(error); + } + Ok(true) + } + + pub(crate) fn active_window_id(&self) -> Result { + self.state + .lock() + .map(|state| state.active_window_id.clone()) + .map_err(|err| err.to_string()) + } + + fn claim_renderer_access( + &self, + window_id: &str, + access_token: &str, + renderer_url: &Url, + ) -> Result<(), String> { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + let persisted = self + .state + .lock() + .map_err(|err| err.to_string())? + .record(window_id) + .is_ok(); + let ephemeral = self + .ephemeral_windows + .lock() + .map_err(|err| err.to_string())? + .contains(window_id); + if !persisted && !ephemeral { + return Err("Unknown client state window".to_string()); + } + self.renderer_access + .claim_for(window_id, access_token, renderer_url) } fn load(&self) -> Result { + let window_id = self.active_window_id()?; + self.load_window(&window_id) + } + + fn load_window(&self, window_id: &str) -> Result { let state = self.state.lock().map_err(|err| err.to_string())?; + let record = match state.record(window_id) { + Ok(record) => record, + Err(_error) + if self + .ephemeral_windows + .lock() + .map_err(|err| err.to_string())? + .contains(window_id) => + { + return Ok(ClientStateLoadResult { + is_primary: false, + restore_enabled: false, + snapshot: Value::Null, + partition_protocol_version: Some(partitions::PROTOCOL_VERSION), + }); + } + Err(error) => return Err(error), + }; let is_primary = self.is_primary(); Ok(ClientStateLoadResult { is_primary, restore_enabled: if is_primary || !self.process.is_registered() { - state.restore_enabled + record.restore_enabled } else { - true + false }, - snapshot: if is_primary && state.restore_enabled { - state.snapshot.clone().unwrap_or(Value::Null) + snapshot: if is_primary && record.restore_enabled { + record.snapshot.clone().unwrap_or(Value::Null) } else { Value::Null }, + partition_protocol_version: Some(partitions::PROTOCOL_VERSION), }) } + #[cfg(test)] + fn load_partition_guarded( + &self, + key: &str, + access_valid: impl Fn() -> bool, + ) -> Result, String> { + let window_id = self.active_window_id()?; + self.load_partition_guarded_for(&window_id, key, access_valid) + } + + fn load_partition_guarded_for( + &self, + window_id: &str, + key: &str, + access_valid: impl Fn() -> bool, + ) -> Result, String> { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !access_valid() { + return Err( + "Client state renderer authority changed before partition read".to_string(), + ); + } + if !partitions::valid_key(key) { + return Err("Invalid client state partition key".to_string()); + } + if !self.is_primary() { + return Ok(None); + } + let state = self.state.lock().map_err(|err| err.to_string())?; + let record = state.record(window_id)?; + if !record.restore_enabled + || !record.partition_root_supported + || !record + .partition_keys + .as_ref() + .is_some_and(|keys| keys.iter().any(|candidate| candidate == key)) + { + return Ok(None); + } + drop(state); + self.partition_store() + .load(key, &|| self.is_primary() && access_valid()) + } + #[cfg(test)] fn save_snapshot(&self, snapshot: Value) -> Result { - self.save_snapshot_guarded(snapshot, || true) + let window_id = self.active_window_id()?; + self.save_snapshot_guarded_for(&window_id, snapshot, || true) } fn save_snapshot_guarded( &self, snapshot: Value, access_valid: impl Fn() -> bool, + ) -> Result { + let window_id = self.active_window_id()?; + self.save_snapshot_guarded_for(&window_id, snapshot, access_valid) + } + + fn save_snapshot_guarded_for( + &self, + window_id: &str, + snapshot: Value, + access_valid: impl Fn() -> bool, ) -> Result { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; if !access_valid() { @@ -323,23 +469,99 @@ impl ClientState { if !self.is_primary() { return Ok(false); } - if self.normal_writes_suppressed()? { + if self.normal_writes_suppressed(window_id)? { return Ok(true); } if serialized_value_size(&snapshot)? > MAX_CLIENT_SNAPSHOT_BYTES { return Err("Client snapshot exceeds the 1 MiB limit".to_string()); } - self.mutate_and_write(|state| state.snapshot = Some(snapshot), &access_valid) + let result = self.mutate_and_write( + window_id, + |state| { + let record = state.record_mut(window_id)?; + record.snapshot = Some(snapshot); + record.partition_protocol_version = None; + record.partition_keys = None; + record.partition_root_supported = false; + Ok(()) + }, + &access_valid, + )?; + self.collect_partitions(&access_valid); + Ok(result) + } + + #[cfg(test)] + fn commit_partitions_guarded( + &self, + payload: partitions::PartitionCommit, + access_valid: impl Fn() -> bool, + ) -> Result { + let window_id = self.active_window_id()?; + self.commit_partitions_guarded_for(&window_id, payload, access_valid) + } + + fn commit_partitions_guarded_for( + &self, + window_id: &str, + payload: partitions::PartitionCommit, + access_valid: impl Fn() -> bool, + ) -> Result { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !access_valid() { + return Err("Client state renderer authority changed before mutation".to_string()); + } + let commit = payload.validate()?; + if !self.is_primary() { + return Ok(false); + } + if self.normal_writes_suppressed(window_id)? { + return Ok(true); + } + self.partition_store() + .prepare(&commit, &|| self.is_primary() && access_valid())?; + let snapshot = commit.snapshot.clone(); + let partition_keys = commit.partition_keys.clone(); + let result = self.mutate_and_write( + window_id, + |state| { + let record = state.record_mut(window_id)?; + record.snapshot = Some(snapshot); + record.partition_protocol_version = Some(partitions::PROTOCOL_VERSION); + record.partition_keys = Some(partition_keys); + record.partition_root_supported = true; + Ok(()) + }, + &access_valid, + )?; + self.collect_partitions(&access_valid); + Ok(result) + } + + fn collect_partitions(&self, access_valid: &dyn Fn() -> bool) { + let retained = self + .state + .lock() + .map(|state| state.retained_partition_keys()) + .unwrap_or_default(); + if let Err(err) = self + .partition_store() + .sweep(&retained, &|| self.is_primary() && access_valid()) + { + eprintln!("[client-state] failed to sweep partitions: {err}"); + } } #[cfg(test)] fn set_restore_enabled(&self, enabled: bool) -> Result { - self.set_restore_enabled_guarded(enabled, || true) + let window_id = self.active_window_id()?; + self.set_restore_enabled_guarded(&window_id, enabled, || true) } fn set_restore_enabled_guarded( &self, + window_id: &str, enabled: bool, access_valid: impl Fn() -> bool, ) -> Result { @@ -358,25 +580,40 @@ impl ClientState { { return Ok(false); } - self.mutate_and_write( + let result = self.mutate_and_write( + window_id, |state| { - state.restore_enabled = enabled; + let record = state.record_mut(window_id)?; + record.restore_enabled = enabled; if !enabled { - state.snapshot = None; - state.window = None; + record.snapshot = None; + record.window = None; + record.partition_protocol_version = None; + record.partition_keys = None; + record.partition_root_supported = false; } - state.writes_enabled = enabled; + record.writes_enabled = enabled; + Ok(()) }, &access_valid, - ) + )?; + if !enabled { + self.collect_partitions(&access_valid); + } + Ok(result) } #[cfg(test)] fn clear(&self) -> Result { - self.clear_guarded(|| true) + let window_id = self.active_window_id()?; + self.clear_guarded(&window_id, || true) } - fn clear_guarded(&self, access_valid: impl Fn() -> bool) -> Result { + fn clear_guarded( + &self, + window_id: &str, + access_valid: impl Fn() -> bool, + ) -> Result { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; if !access_valid() { return Err("Client state renderer authority changed before mutation".to_string()); @@ -384,33 +621,55 @@ impl ClientState { if !self.is_primary() { return Ok(false); } - self.mutate_and_write( + let result = self.mutate_and_write( + window_id, |state| { - if state.unsupported_future_envelope { - *state = PersistedClientState::default(); + let clearing_unsupported = state.unsupported_future_envelope; + state.unsupported_future_envelope = false; + let record = state.record_mut(window_id)?; + if clearing_unsupported { + record.snapshot = None; + record.window = None; + record.partition_protocol_version = None; + record.partition_keys = None; + record.partition_root_supported = false; + record.writes_enabled = true; } else { - state.snapshot = None; - state.window = None; - state.writes_enabled = false; + record.snapshot = None; + record.window = None; + record.partition_protocol_version = None; + record.partition_keys = None; + record.partition_root_supported = false; + record.writes_enabled = false; } + Ok(()) }, &access_valid, - ) + )?; + self.collect_partitions(&access_valid); + Ok(result) } fn flush(&self) -> Result<(), String> { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; - if self.is_primary() && !self.normal_writes_suppressed()? { + let unsupported = self + .state + .lock() + .map_err(|err| err.to_string())? + .unsupported_future_envelope; + if self.is_primary() && !unsupported { self.write_current_state()?; } Ok(()) } - fn normal_writes_suppressed(&self) -> Result { - self.state - .lock() - .map(|state| !state.writes_enabled || state.unsupported_future_envelope) - .map_err(|err| err.to_string()) + fn normal_writes_suppressed(&self, window_id: &str) -> Result { + let state = self.state.lock().map_err(|err| err.to_string())?; + Ok(state.unsupported_future_envelope || !state.record(window_id)?.writes_enabled) + } + + fn partition_store(&self) -> partitions::PartitionStore { + partitions::PartitionStore::new(self.state_path.parent().unwrap_or(Path::new(""))) } fn write_current_state(&self) -> Result<(), String> { @@ -433,13 +692,14 @@ impl ClientState { fn mutate_and_write( &self, - mutate: impl FnOnce(&mut PersistedClientState), + _window_id: &str, + mutate: impl FnOnce(&mut PersistedClientState) -> Result<(), String>, replacement_valid: &dyn Fn() -> bool, ) -> Result { let previous_state = { let mut state = self.state.lock().map_err(|err| err.to_string())?; let previous = state.clone(); - mutate(&mut state); + mutate(&mut state)?; previous }; @@ -452,7 +712,78 @@ impl ClientState { } } + pub(crate) fn add_window(&self, window_id: String) -> Result { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !self.is_primary() { + return Ok(false); + } + let previous = { + let mut state = self.state.lock().map_err(|err| err.to_string())?; + if state.unsupported_future_envelope { + return Ok(false); + } + let previous = state.clone(); + state.add_window(window_id.clone())?; + previous + }; + if let Err(err) = self.write_current_state() { + *self.state.lock().map_err(|lock_err| lock_err.to_string())? = previous; + return Err(err); + } + self.zoom_levels + .lock() + .map_err(|err| err.to_string())? + .insert(window_id, DEFAULT_ZOOM_LEVEL); + Ok(true) + } + + pub(crate) fn register_ephemeral_window(&self, window_id: String) { + if let Ok(mut windows) = self.ephemeral_windows.lock() { + windows.insert(window_id); + } + } + + pub(crate) fn unregister_window(&self, window_id: &str) { + self.renderer_access.remove(window_id); + if let Ok(mut windows) = self.ephemeral_windows.lock() { + windows.remove(window_id); + } + if let Ok(mut flushes) = self.renderer_flush.windows.lock() { + flushes.remove(window_id); + } + } + + pub(crate) fn remove_window(&self, window_id: &str) -> Result { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !self.is_primary() { + return Ok(false); + } + let previous = { + let mut state = self.state.lock().map_err(|err| err.to_string())?; + if state.unsupported_future_envelope { + return Ok(false); + } + let previous = state.clone(); + if !state.remove_window(window_id)? { + return Ok(false); + } + previous + }; + if let Err(err) = self.write_current_state() { + *self.state.lock().map_err(|lock_err| lock_err.to_string())? = previous; + return Err(err); + } + self.renderer_access.remove(window_id); + self.zoom_levels + .lock() + .map_err(|err| err.to_string())? + .remove(window_id); + self.collect_partitions(&|| true); + Ok(true) + } + fn release_locks(&self) { + // Lock order fences takeover until root publication and partition GC leave write_lock. let _write = self .write_lock .lock() @@ -460,23 +791,34 @@ impl ClientState { self.process.release_locks(); } - pub(crate) fn wait_for_renderer_flush(&self, app: &AppHandle, require_claim: bool) { + pub(crate) fn wait_for_renderer_flush( + &self, + app: &AppHandle, + window_label: &str, + window_id: &str, + require_claim: bool, + ) { let _request = self .renderer_flush .request_lock .lock() .unwrap_or_else(|err| err.into_inner()); - if require_claim && !self.renderer_access.is_claimed() { + if require_claim && !self.renderer_access.is_claimed_for(window_id) { return; } - let Some(window) = app.get_webview_window("main") else { + let Some(window) = app.get_webview_window(window_label) else { return; }; - let generation = self - .renderer_flush - .next_generation - .fetch_add(1, Ordering::SeqCst) - + 1; + let generation = { + let mut windows = self + .renderer_flush + .windows + .lock() + .unwrap_or_else(|error| error.into_inner()); + let state = windows.entry(window_id.to_string()).or_default(); + state.0 += 1; + state.0 + }; if let Err(err) = window.emit( "client-state:navigation-flush-requested", RendererFlushRequest { generation }, @@ -486,23 +828,26 @@ impl ClientState { } let deadline = Instant::now() + RENDERER_FLUSH_TIMEOUT; - while self.renderer_flush.next_generation.load(Ordering::SeqCst) == generation - && self - .renderer_flush - .acknowledged_generation - .load(Ordering::SeqCst) - != generation + while self + .renderer_flush + .windows + .lock() + .ok() + .and_then(|windows| windows.get(window_id).copied()) + .is_some_and(|state| state.0 == generation && state.1 != generation) && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(10)); } } - fn acknowledge_renderer_flush(&self, generation: u64) { - if self.renderer_flush.next_generation.load(Ordering::SeqCst) == generation { - self.renderer_flush - .acknowledged_generation - .store(generation, Ordering::SeqCst); + fn acknowledge_renderer_flush(&self, window_id: &str, generation: u64) { + if let Ok(mut windows) = self.renderer_flush.windows.lock() { + if let Some(state) = windows.get_mut(window_id) { + if state.0 == generation { + state.1 = generation; + } + } } } } @@ -519,45 +864,13 @@ fn read_client_state(path: &Path) -> PersistedClientState { Err(err) if err.kind() == std::io::ErrorKind::NotFound => PersistedClientState::default(), Err(err) => { eprintln!("[client-state] failed to read state: {err}"); - PersistedClientState::default() + envelope::unsupported() } } } fn parse_client_state(bytes: &[u8]) -> PersistedClientState { - let Ok(Value::Object(value)) = serde_json::from_slice::(bytes) else { - return PersistedClientState::default(); - }; - let version = value.get("version").and_then(Value::as_u64); - if version.is_some_and(|version| version > CLIENT_STATE_VERSION) { - return PersistedClientState { - restore_enabled: false, - unsupported_future_envelope: true, - writes_enabled: false, - ..PersistedClientState::default() - }; - } - if version != Some(CLIENT_STATE_VERSION) { - return PersistedClientState::default(); - } - - let snapshot = value.get("snapshot").cloned().filter(|snapshot| { - serialized_value_size(snapshot) - .map(|size| size <= MAX_CLIENT_SNAPSHOT_BYTES) - .unwrap_or(false) - }); - let restore_enabled = value - .get("restoreEnabled") - .and_then(Value::as_bool) - .unwrap_or(true); - PersistedClientState { - version: CLIENT_STATE_VERSION, - restore_enabled, - snapshot, - window: value.get("window").and_then(window::normalize_window_state), - unsupported_future_envelope: false, - writes_enabled: restore_enabled, - } + envelope::parse(bytes) } fn legacy_candidate( @@ -568,7 +881,11 @@ fn legacy_candidate( let Value::Object(value) = serde_json::from_slice::(&bytes).ok()? else { return None; }; - if value.get("version").and_then(Value::as_u64) != Some(CLIENT_STATE_VERSION) { + if value + .get("version") + .and_then(envelope::exact_nonnegative_safe_integer) + != Some(envelope::LEGACY_MONOLITHIC_VERSION) + { return None; } let saved_at = value @@ -578,7 +895,10 @@ fn legacy_candidate( .and_then(Value::as_i64) .unwrap_or(-1); let mut parsed = parse_client_state(&bytes); - parsed.window = None; + if parsed.unsupported_future_envelope { + return None; + } + parsed.active_mut().window = None; Some((parsed, value.contains_key("snapshot"), saved_at, host)) } @@ -593,8 +913,12 @@ fn has_future_legacy_state(tauri_data_dir: &Path, electron_data_dir: Option<&Pat fs::read(path) .ok() .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|value| value.get("version").and_then(Value::as_u64)) - .is_some_and(|version| version > CLIENT_STATE_VERSION) + .and_then(|value| { + value + .get("version") + .and_then(envelope::exact_nonnegative_safe_integer) + }) + .is_some_and(|version| version > envelope::LEGACY_MONOLITHIC_VERSION) }) } @@ -614,8 +938,9 @@ fn migrate_legacy_state( .collect::>(); candidates.sort_by(|left, right| { left.0 + .active() .restore_enabled - .cmp(&right.0.restore_enabled) + .cmp(&right.0.active().restore_enabled) .then_with(|| left.1.cmp(&right.1)) .then_with(|| right.2.cmp(&left.2)) .then_with(|| right.3.cmp(left.3)) @@ -623,7 +948,21 @@ fn migrate_legacy_state( let Some((state, _, _, _)) = candidates.first() else { return Ok(()); }; - let bytes = serde_json::to_vec(state).map_err(|err| err.to_string())?; + let record = state.active(); + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct LegacyState<'a> { + version: u64, + restore_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + snapshot: Option<&'a Value>, + } + let bytes = serde_json::to_vec(&LegacyState { + version: envelope::LEGACY_MONOLITHIC_VERSION, + restore_enabled: record.restore_enabled, + snapshot: record.snapshot.as_ref(), + }) + .map_err(|err| err.to_string())?; if let Some(parent) = state_path.parent() { fs::create_dir_all(parent) .map_err(|err| format!("failed to create shared client-state directory: {err}"))?; @@ -645,6 +984,18 @@ fn copy_legacy_shared_state( Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(err) => return Err(format!("failed to read legacy shared client state: {err}")), }; + let version = serde_json::from_slice::(&bytes) + .ok() + .and_then(|value| { + value + .get("version") + .and_then(envelope::exact_nonnegative_safe_integer) + }); + if version != Some(envelope::LEGACY_MONOLITHIC_VERSION) + || parse_client_state(&bytes).unsupported_future_envelope + { + return Ok(()); + } let parent = state_path .parent() .ok_or_else(|| format!("state path has no parent: {}", state_path.display()))?; @@ -660,7 +1011,7 @@ fn copy_legacy_shared_state( return Err("Client state ownership changed before atomic replacement".to_string()); } match temporary.persist_noclobber(state_path) { - Ok(_) => Ok(()), + Ok(_) => partitions::sync_directory(parent), Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), Err(err) => Err(format!( "failed to publish copied legacy shared client state: {}", @@ -695,6 +1046,7 @@ fn write_atomically( temporary .persist(path) .map_err(|err| format!("failed to replace state file: {}", err.error))?; + partitions::sync_directory(parent)?; Ok(()) } diff --git a/packages/tauri-app/src-tauri/src/client_state/access.rs b/packages/tauri-app/src-tauri/src/client_state/access.rs index 31181c62..17329c51 100644 --- a/packages/tauri-app/src-tauri/src/client_state/access.rs +++ b/packages/tauri-app/src-tauri/src/client_state/access.rs @@ -1,9 +1,10 @@ +use std::collections::HashMap; use std::sync::Mutex; use url::Url; #[derive(Default)] pub(super) struct RendererAccess { - state: Mutex, + state: Mutex>, } #[derive(Default)] @@ -15,6 +16,7 @@ struct RendererAccessState { } pub(super) struct PendingNavigation { + window_id: String, previous_origin: Option, staged_origin: Option, } @@ -29,12 +31,22 @@ fn origin_key(url: &Url) -> Result { impl RendererAccess { pub(super) fn claim(&self, access_token: &str, renderer_url: &Url) -> Result<(), String> { + self.claim_for("test-window", access_token, renderer_url) + } + + pub(super) fn claim_for( + &self, + window_id: &str, + access_token: &str, + renderer_url: &Url, + ) -> Result<(), String> { if access_token.is_empty() { return Err("Client state access token must not be empty".to_string()); } let renderer_origin = origin_key(renderer_url)?; - let mut state = self.state.lock().map_err(|err| err.to_string())?; + let mut states = self.state.lock().map_err(|err| err.to_string())?; + let state = states.entry(window_id.to_string()).or_default(); if state.token.is_none() || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) { @@ -55,12 +67,24 @@ impl RendererAccess { } pub(super) fn validate(&self, access_token: &str, renderer_url: &Url) -> Result { + self.validate_for("test-window", access_token, renderer_url) + } + + pub(super) fn validate_for( + &self, + window_id: &str, + access_token: &str, + renderer_url: &Url, + ) -> Result { if access_token.is_empty() { return Err("Client state access token must not be empty".to_string()); } let renderer_origin = origin_key(renderer_url)?; - let state = self.state.lock().map_err(|err| err.to_string())?; + let states = self.state.lock().map_err(|err| err.to_string())?; + let Some(state) = states.get(window_id) else { + return Err("Client state access has not been claimed by this renderer".to_string()); + }; if state.token.as_deref() == Some(access_token) && state.committed_origin.as_deref() == Some(renderer_origin.as_str()) { @@ -76,21 +100,31 @@ impl RendererAccess { } pub(super) fn is_generation_current(&self, generation: u64) -> bool { + self.is_generation_current_for("test-window", generation) + } + + pub(super) fn is_generation_current_for(&self, window_id: &str, generation: u64) -> bool { self.state .lock() - .map(|state| state.generation == generation) + .map(|state| { + state + .get(window_id) + .is_some_and(|state| state.generation == generation) + }) .unwrap_or(false) } - pub(super) fn allows_claim_origin(&self, renderer_url: &Url) -> bool { + pub(super) fn allows_claim_origin_for(&self, window_id: &str, renderer_url: &Url) -> bool { let Ok(renderer_origin) = origin_key(renderer_url) else { return false; }; self.state .lock() - .map(|state| { - state.committed_origin.as_deref() == Some(renderer_origin.as_str()) - || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) + .map(|states| { + states.get(window_id).is_some_and(|state| { + state.committed_origin.as_deref() == Some(renderer_origin.as_str()) + || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) + }) }) .unwrap_or(false) } @@ -99,7 +133,16 @@ impl RendererAccess { &self, target_url: Option<&Url>, ) -> Result { - let mut state = self.state.lock().map_err(|err| err.to_string())?; + self.begin_navigation_for("test-window", target_url) + } + + pub(super) fn begin_navigation_for( + &self, + window_id: &str, + target_url: Option<&Url>, + ) -> Result { + let mut states = self.state.lock().map_err(|err| err.to_string())?; + let state = states.entry(window_id.to_string()).or_default(); let previous_origin = state.pending_origin.clone(); let staged_origin = match target_url { Some(url) => Some(origin_key(url)?), @@ -109,22 +152,35 @@ impl RendererAccess { }; state.pending_origin = staged_origin.clone(); Ok(PendingNavigation { + window_id: window_id.to_string(), previous_origin, staged_origin, }) } pub(super) fn cancel_navigation(&self, navigation: PendingNavigation) { - let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - if state.pending_origin == navigation.staged_origin { - state.pending_origin = navigation.previous_origin; + let mut states = self.state.lock().unwrap_or_else(|err| err.into_inner()); + if let Some(state) = states.get_mut(&navigation.window_id) { + if state.pending_origin == navigation.staged_origin { + state.pending_origin = navigation.previous_origin; + } } } - pub(super) fn is_claimed(&self) -> bool { + pub(super) fn is_claimed_for(&self, window_id: &str) -> bool { self.state .lock() - .map(|state| state.token.is_some()) + .map(|state| { + state + .get(window_id) + .is_some_and(|state| state.token.is_some()) + }) .unwrap_or(false) } + + pub(super) fn remove(&self, window_id: &str) { + if let Ok(mut states) = self.state.lock() { + states.remove(window_id); + } + } } diff --git a/packages/tauri-app/src-tauri/src/client_state/commands.rs b/packages/tauri-app/src-tauri/src/client_state/commands.rs index f22b6423..1bbf096c 100644 --- a/packages/tauri-app/src-tauri/src/client_state/commands.rs +++ b/packages/tauri-app/src-tauri/src/client_state/commands.rs @@ -1,4 +1,4 @@ -use super::{ClientState, ClientStateLoadResult}; +use super::{partitions::PartitionCommit, ClientState, ClientStateLoadResult}; use crate::AppState; use serde_json::Value; use tauri::{AppHandle, State, WebviewWindow}; @@ -33,25 +33,27 @@ pub(super) fn is_allowed_client_state_origin(url: &Url, managed_cli_url: Option< || is_dev_renderer_origin(url) } -fn main_window_url(window: &WebviewWindow) -> Result { - if window.label() != "main" { - return Err( - "Client state commands are only available to the local main window".to_string(), - ); - } - +fn local_window_url(window: &WebviewWindow) -> Result { + crate::identity::local_window_id(window.label())?; window .url() .map_err(|err| format!("failed to inspect current renderer URL: {err}")) } +fn trusted_window_id(window: &WebviewWindow) -> Result { + crate::identity::local_window_id(window.label()) +} + fn validate_claim_origin( current_url: &Url, + window_id: &str, app_state: &AppState, state: &ClientState, ) -> Result<(), String> { let status = app_state.manager.status(); - if state.renderer_access.allows_claim_origin(current_url) + if state + .renderer_access + .allows_claim_origin_for(window_id, current_url) || is_allowed_client_state_origin(current_url, status.url.as_deref()) { Ok(()) @@ -64,9 +66,13 @@ fn validate_access( window: &WebviewWindow, state: &ClientState, access_token: &str, -) -> Result { - let current_url = main_window_url(window)?; - state.renderer_access.validate(access_token, ¤t_url) +) -> Result<(String, u64), String> { + let current_url = local_window_url(window)?; + let window_id = trusted_window_id(window)?; + state + .renderer_access + .validate_for(&window_id, access_token, ¤t_url) + .map(|generation| (window_id, generation)) } #[tauri::command] @@ -76,9 +82,10 @@ pub fn client_state_claim_access( state: State<'_, ClientState>, access_token: String, ) -> Result<(), String> { - let current_url = main_window_url(&window)?; - validate_claim_origin(¤t_url, &app_state, &state)?; - state.claim_renderer_access(&access_token, ¤t_url) + let current_url = local_window_url(&window)?; + let window_id = trusted_window_id(&window)?; + validate_claim_origin(¤t_url, &window_id, &app_state, &state)?; + state.claim_renderer_access(&window_id, &access_token, ¤t_url) } #[tauri::command] @@ -87,8 +94,23 @@ pub fn client_state_load( state: State<'_, ClientState>, access_token: String, ) -> Result { - validate_access(&window, &state, &access_token)?; - state.load() + let (window_id, _) = validate_access(&window, &state, &access_token)?; + state.load_window(&window_id) +} + +#[tauri::command] +pub fn client_state_load_partition( + window: WebviewWindow, + state: State<'_, ClientState>, + access_token: String, + key: String, +) -> Result, String> { + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.load_partition_guarded_for(&window_id, &key, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) + }) } #[tauri::command] @@ -98,9 +120,26 @@ pub fn client_state_save( access_token: String, snapshot: Value, ) -> Result { - let generation = validate_access(&window, &state, &access_token)?; - state.save_snapshot_guarded(snapshot, || { - state.renderer_access.is_generation_current(generation) + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.save_snapshot_guarded_for(&window_id, snapshot, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) + }) +} + +#[tauri::command] +pub fn client_state_commit_partitions( + window: WebviewWindow, + state: State<'_, ClientState>, + access_token: String, + payload: PartitionCommit, +) -> Result { + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.commit_partitions_guarded_for(&window_id, payload, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) }) } @@ -111,9 +150,11 @@ pub fn client_state_set_restore_enabled( access_token: String, enabled: bool, ) -> Result { - let generation = validate_access(&window, &state, &access_token)?; - state.set_restore_enabled_guarded(enabled, || { - state.renderer_access.is_generation_current(generation) + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.set_restore_enabled_guarded(&window_id, enabled, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) }) } @@ -123,8 +164,12 @@ pub fn client_state_clear( state: State<'_, ClientState>, access_token: String, ) -> Result { - let generation = validate_access(&window, &state, &access_token)?; - state.clear_guarded(|| state.renderer_access.is_generation_current(generation)) + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.clear_guarded(&window_id, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) + }) } #[tauri::command] @@ -135,8 +180,8 @@ pub fn client_state_renderer_flushed( access_token: String, generation: u64, ) -> Result<(), String> { - validate_access(&window, &state, &access_token)?; - crate::shutdown::renderer_flushed(app, generation); + let (window_id, _) = validate_access(&window, &state, &access_token)?; + crate::shutdown::renderer_flushed(app, window.label().to_string(), window_id, generation); Ok(()) } @@ -147,7 +192,7 @@ pub fn client_state_navigation_flushed( access_token: String, generation: u64, ) -> Result<(), String> { - validate_access(&window, &state, &access_token)?; - state.acknowledge_renderer_flush(generation); + let (window_id, _) = validate_access(&window, &state, &access_token)?; + state.acknowledge_renderer_flush(&window_id, generation); Ok(()) } diff --git a/packages/tauri-app/src-tauri/src/client_state/envelope.rs b/packages/tauri-app/src-tauri/src/client_state/envelope.rs new file mode 100644 index 00000000..7d946f5e --- /dev/null +++ b/packages/tauri-app/src-tauri/src/client_state/envelope.rs @@ -0,0 +1,378 @@ +use super::{partitions, window}; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; + +pub(super) const VERSION: u64 = 3; +pub(super) const LEGACY_MONOLITHIC_VERSION: u64 = 1; +const LEGACY_PARTITION_VERSION: u64 = 2; +const MAX_WINDOWS: usize = 16; +pub(super) const MAX_SNAPSHOT_BYTES: usize = 1024 * 1024; +const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct WindowRecord { + pub(super) restore_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) partition_protocol_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) partition_keys: Option>, + #[serde(skip)] + pub(super) partition_root_supported: bool, + #[serde(skip)] + pub(super) writes_enabled: bool, +} + +impl Default for WindowRecord { + fn default() -> Self { + Self { + restore_enabled: true, + snapshot: None, + window: None, + partition_protocol_version: None, + partition_keys: None, + partition_root_supported: false, + writes_enabled: true, + } + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PersistedClientState { + version: u64, + pub(super) active_window_id: String, + pub(super) window_order: Vec, + pub(super) windows: HashMap, + #[serde(skip)] + pub(super) unsupported_future_envelope: bool, +} + +impl Default for PersistedClientState { + fn default() -> Self { + Self::with_window_id(uuid::Uuid::new_v4().to_string()) + } +} + +impl PersistedClientState { + fn with_window_id(window_id: String) -> Self { + Self { + version: VERSION, + active_window_id: window_id.clone(), + window_order: vec![window_id.clone()], + windows: HashMap::from([(window_id, WindowRecord::default())]), + unsupported_future_envelope: false, + } + } + + pub(super) fn active(&self) -> &WindowRecord { + self.windows + .get(&self.active_window_id) + .expect("validated client state has an active window") + } + + pub(super) fn active_mut(&mut self) -> &mut WindowRecord { + self.windows + .get_mut(&self.active_window_id) + .expect("validated client state has an active window") + } + + pub(super) fn record(&self, window_id: &str) -> Result<&WindowRecord, String> { + self.windows + .get(window_id) + .ok_or_else(|| "Unknown client state window".to_string()) + } + + pub(super) fn record_mut(&mut self, window_id: &str) -> Result<&mut WindowRecord, String> { + self.windows + .get_mut(window_id) + .ok_or_else(|| "Unknown client state window".to_string()) + } + + pub(super) fn retained_partition_keys(&self) -> Vec { + let mut keys = self + .window_order + .iter() + .filter_map(|id| self.windows.get(id)) + .flat_map(|record| record.partition_keys.iter().flatten().cloned()) + .collect::>() + .into_iter() + .collect::>(); + keys.sort(); + keys + } + + pub(super) fn add_window(&mut self, window_id: String) -> Result<(), String> { + if !valid_window_id(&window_id) { + return Err("Invalid client state window ID".to_string()); + } + if self.window_order.len() >= MAX_WINDOWS { + return Err("Too many client state windows".to_string()); + } + if self.windows.contains_key(&window_id) { + return Err("Client state window already exists".to_string()); + } + if self.window_order.is_empty() { + self.active_window_id = window_id.clone(); + } + self.window_order.push(window_id.clone()); + self.windows.insert(window_id, WindowRecord::default()); + Ok(()) + } + + pub(super) fn remove_window(&mut self, window_id: &str) -> Result { + if !self.windows.contains_key(window_id) { + return Err("Unknown client state window".to_string()); + } + self.window_order.retain(|id| id != window_id); + self.windows.remove(window_id); + if self.active_window_id == window_id && !self.window_order.is_empty() { + self.active_window_id = self.window_order[0].clone(); + } + Ok(true) + } +} + +pub(super) fn valid_window_id(value: &str) -> bool { + value.len() == 36 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte) || byte == b'-') + && uuid::Uuid::parse_str(value) + .map(|uuid| uuid.to_string() == value) + .unwrap_or(false) +} + +fn has_only_keys(value: &serde_json::Map, allowed: &[&str]) -> bool { + value.keys().all(|key| allowed.contains(&key.as_str())) +} + +fn value_size(value: &Value) -> Option { + serde_json::to_vec(value).ok().map(|bytes| bytes.len()) +} + +pub(super) fn exact_nonnegative_safe_integer(value: &Value) -> Option { + let number = value.as_number()?.as_f64()?; + (number.is_finite() && number >= 0.0 && number <= MAX_SAFE_INTEGER && number.fract() == 0.0) + .then_some(number as u64) +} + +fn parse_record(value: &Value) -> Option { + let value = value.as_object()?; + if !has_only_keys( + value, + &[ + "restoreEnabled", + "snapshot", + "window", + "partitionProtocolVersion", + "partitionKeys", + ], + ) { + return None; + } + let restore_enabled = value.get("restoreEnabled")?.as_bool()?; + let snapshot = value.get("snapshot").cloned(); + let window = if value.contains_key("window") { + Some(window::normalize_window_state(value.get("window")?)?) + } else { + None + }; + let has_protocol = value.contains_key("partitionProtocolVersion"); + let has_keys = value.contains_key("partitionKeys"); + let (partition_protocol_version, partition_keys, partition_root_supported) = if has_protocol + || has_keys + { + if !has_protocol + || !has_keys + || exact_nonnegative_safe_integer(value.get("partitionProtocolVersion")?)? + != partitions::PROTOCOL_VERSION + { + return None; + } + let keys = partitions::validate_keys(value.get("partitionKeys")?)?; + let root = snapshot.as_ref().and_then(partitions::validate_root)?; + if root != keys || snapshot.as_ref().and_then(value_size)? > partitions::MAX_ROOT_BYTES { + return None; + } + (Some(partitions::PROTOCOL_VERSION), Some(keys), true) + } else { + if snapshot + .as_ref() + .and_then(value_size) + .is_some_and(|size| size > MAX_SNAPSHOT_BYTES) + { + return None; + } + (None, None, false) + }; + Some(WindowRecord { + restore_enabled, + snapshot, + window, + partition_protocol_version, + partition_keys, + partition_root_supported, + writes_enabled: restore_enabled, + }) +} + +fn parse_v3(value: &serde_json::Map) -> Option { + if !has_only_keys( + value, + &["version", "activeWindowId", "windowOrder", "windows"], + ) { + return None; + } + let active_window_id = value.get("activeWindowId")?.as_str()?.to_string(); + if !valid_window_id(&active_window_id) { + return None; + } + let order = value.get("windowOrder")?.as_array()?; + if order.len() > MAX_WINDOWS { + return None; + } + let mut seen = HashSet::new(); + let mut window_order = Vec::with_capacity(order.len()); + for id in order { + let id = id.as_str()?; + if !valid_window_id(id) || !seen.insert(id) { + return None; + } + window_order.push(id.to_string()); + } + if !window_order.is_empty() && !seen.contains(active_window_id.as_str()) { + return None; + } + let source = value.get("windows")?.as_object()?; + if source.len() != window_order.len() || source.keys().any(|id| !seen.contains(id.as_str())) { + return None; + } + let mut windows = HashMap::with_capacity(window_order.len()); + for id in &window_order { + windows.insert(id.clone(), parse_record(source.get(id)?)?); + } + Some(PersistedClientState { + version: VERSION, + active_window_id, + window_order, + windows, + unsupported_future_envelope: false, + }) +} + +fn parse_legacy( + value: &serde_json::Map, + bytes: &[u8], +) -> Option { + let version = exact_nonnegative_safe_integer(value.get("version")?)?; + let mut state = PersistedClientState::with_window_id(deterministic_legacy_window_id(bytes)); + let record = if version == LEGACY_PARTITION_VERSION { + if !has_only_keys( + value, + &[ + "version", + "restoreEnabled", + "snapshot", + "window", + "protocolVersion", + "partitionKeys", + ], + ) { + return None; + } + let restore_enabled = value.get("restoreEnabled")?.as_bool()?; + let snapshot = value.get("snapshot")?.clone(); + let keys = partitions::validate_keys(value.get("partitionKeys")?)?; + if exact_nonnegative_safe_integer(value.get("protocolVersion")?)? + != partitions::PROTOCOL_VERSION + || partitions::validate_root(&snapshot)? != keys + || value_size(&snapshot)? > partitions::MAX_ROOT_BYTES + { + return None; + } + WindowRecord { + restore_enabled, + snapshot: Some(snapshot), + window: if value.contains_key("window") { + Some(window::normalize_window_state(value.get("window")?)?) + } else { + None + }, + partition_protocol_version: Some(partitions::PROTOCOL_VERSION), + partition_keys: Some(keys), + partition_root_supported: true, + writes_enabled: restore_enabled, + } + } else if version == LEGACY_MONOLITHIC_VERSION { + if !has_only_keys(value, &["version", "restoreEnabled", "snapshot", "window"]) { + return None; + } + let restore_enabled = match value.get("restoreEnabled") { + Some(value) => value.as_bool()?, + None => true, + }; + let snapshot = value.get("snapshot").cloned(); + if snapshot + .as_ref() + .and_then(value_size) + .is_some_and(|size| size > MAX_SNAPSHOT_BYTES) + { + return None; + } + WindowRecord { + restore_enabled, + snapshot, + window: if value.contains_key("window") { + Some(window::normalize_window_state(value.get("window")?)?) + } else { + None + }, + writes_enabled: restore_enabled, + ..WindowRecord::default() + } + } else { + return None; + }; + *state.active_mut() = record; + Some(state) +} + +pub(super) fn deterministic_legacy_window_id(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut id = [0_u8; 16]; + id.copy_from_slice(&digest[..16]); + id[6] = (id[6] & 0x0f) | 0x50; + id[8] = (id[8] & 0x3f) | 0x80; + uuid::Uuid::from_bytes(id).to_string() +} + +pub(super) fn parse(bytes: &[u8]) -> PersistedClientState { + let Ok(Value::Object(value)) = serde_json::from_slice::(bytes) else { + return unsupported(); + }; + let version = value + .get("version") + .and_then(exact_nonnegative_safe_integer); + let parsed = if version == Some(VERSION) { + parse_v3(&value) + } else { + parse_legacy(&value, bytes) + }; + parsed.unwrap_or_else(unsupported) +} + +pub(super) fn unsupported() -> PersistedClientState { + let mut state = PersistedClientState::default(); + let record = state.active_mut(); + record.restore_enabled = false; + record.writes_enabled = false; + state.unsupported_future_envelope = true; + state +} diff --git a/packages/tauri-app/src-tauri/src/client_state/navigation.rs b/packages/tauri-app/src-tauri/src/client_state/navigation.rs index ef49dbc0..3e4388c1 100644 --- a/packages/tauri-app/src-tauri/src/client_state/navigation.rs +++ b/packages/tauri-app/src-tauri/src/client_state/navigation.rs @@ -1,11 +1,11 @@ use super::ClientState; -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::sync::{LazyLock, Mutex}; use tauri::{AppHandle, Manager}; use url::Url; -static NAVIGATIONS: LazyLock>> = - LazyLock::new(|| Mutex::new(NavigationQueue::default())); +static NAVIGATIONS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); type Operation = Box Result<(), String> + Send + 'static>; type NavigationGuard = Box bool + Send + 'static>; @@ -19,6 +19,7 @@ pub(crate) enum NavigationKind { struct NavigationOperation { app: AppHandle, + window_label: String, target_url: Option, is_current: NavigationGuard, navigate: Operation, @@ -108,26 +109,30 @@ impl NavigationQueue { } } -pub(crate) fn before_main_window_navigation( +pub(crate) fn before_window_navigation( app: &AppHandle, + window_label: String, kind: NavigationKind, target_url: Option, navigate: impl FnOnce(AppHandle) -> Result<(), String> + Send + 'static, ) { - before_main_window_navigation_if(app, kind, target_url, || true, navigate); + before_window_navigation_if(app, window_label, kind, target_url, || true, navigate); } -pub(crate) fn before_main_window_navigation_if( +pub(crate) fn before_window_navigation_if( app: &AppHandle, + window_label: String, kind: NavigationKind, target_url: Option, is_current: impl Fn() -> bool + Send + 'static, navigate: impl FnOnce(AppHandle) -> Result<(), String> + Send + 'static, ) { + let queue_label = window_label.clone(); let request = QueuedNavigation::new( kind, NavigationOperation { app: app.clone(), + window_label, target_url, is_current: Box::new(is_current), navigate: Box::new(navigate), @@ -136,59 +141,75 @@ pub(crate) fn before_main_window_navigation_if( let start_worker = NAVIGATIONS .lock() .unwrap_or_else(|err| err.into_inner()) + .entry(queue_label.clone()) + .or_default() .enqueue(request); if start_worker { - std::thread::spawn(run_navigation_queue); + std::thread::spawn(move || run_navigation_queue(queue_label)); } } -fn run_navigation_queue() { +fn complete_active(window_label: &str) { + if let Some(queue) = NAVIGATIONS + .lock() + .unwrap_or_else(|err| err.into_inner()) + .get_mut(window_label) + { + queue.complete_active(); + } +} + +fn run_navigation_queue(window_label: String) { loop { - let request = NAVIGATIONS - .lock() - .unwrap_or_else(|err| err.into_inner()) - .next(); + let request = { + let mut queues = NAVIGATIONS.lock().unwrap_or_else(|err| err.into_inner()); + let request = queues + .get_mut(&window_label) + .and_then(NavigationQueue::next); + if request.is_none() { + queues.remove(&window_label); + } + request + }; let Some(request) = request else { return; }; let NavigationOperation { app, + window_label, target_url, is_current, navigate, } = request.value; if !is_current() { - NAVIGATIONS - .lock() - .unwrap_or_else(|err| err.into_inner()) - .complete_active(); + complete_active(&window_label); continue; } - if let Some(state) = app.try_state::() { - state.wait_for_renderer_flush(&app, true); + let window_id = crate::identity::local_window_id(&window_label).ok(); + if let (Some(state), Some(window_id)) = + (app.try_state::(), window_id.as_deref()) + { + state.wait_for_renderer_flush(&app, &window_label, window_id, true); } if !is_current() { - NAVIGATIONS - .lock() - .unwrap_or_else(|err| err.into_inner()) - .complete_active(); + complete_active(&window_label); continue; } let result = crate::shutdown::with_navigation_authority(&app, || { let state = app.try_state::(); - execute_navigation(state.as_deref(), target_url.as_ref(), || { - navigate(app.clone()) - }) + execute_navigation_for( + state.as_deref(), + window_id.as_deref(), + target_url.as_ref(), + || navigate(app.clone()), + ) }); if let Some(Err(err)) = result { eprintln!("[client-state] navigation failed: {err}"); } - NAVIGATIONS - .lock() - .unwrap_or_else(|err| err.into_inner()) - .complete_active(); + complete_active(&window_label); } } @@ -196,9 +217,24 @@ fn execute_navigation( state: Option<&ClientState>, target_url: Option<&Url>, navigate: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let window_id = state.map(ClientState::active_window_id).transpose()?; + execute_navigation_for(state, window_id.as_deref(), target_url, navigate) +} + +fn execute_navigation_for( + state: Option<&ClientState>, + window_id: Option<&str>, + target_url: Option<&Url>, + navigate: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { let pending = state - .map(|state| state.renderer_access.begin_navigation(target_url)) + .zip(window_id) + .map(|(state, window_id)| { + state + .renderer_access + .begin_navigation_for(window_id, target_url) + }) .transpose()?; let result = navigate(); if result.is_err() { @@ -262,15 +298,37 @@ mod tests { assert_eq!(queue.next().unwrap().value, "reload"); } + #[test] + fn identical_actions_in_different_window_queues_do_not_coalesce() { + let mut queues: HashMap<&str, NavigationQueue<&str>> = HashMap::new(); + assert!(queues + .entry("local-one") + .or_default() + .enqueue(queued(NavigationKind::Reload, "one"))); + assert!(queues + .entry("local-two") + .or_default() + .enqueue(queued(NavigationKind::Reload, "two"))); + assert_eq!( + queues.get_mut("local-one").unwrap().next().unwrap().value, + "one" + ); + assert_eq!( + queues.get_mut("local-two").unwrap().next().unwrap().value, + "two" + ); + } + #[test] fn failed_navigation_preserves_renderer_access_and_runs_once() { let directory = tempfile::tempdir().unwrap(); let state = ClientState::initialize_at(directory.path()).unwrap(); let renderer_url = url::Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + let window_id = state.active_window_id().unwrap(); let calls = Arc::new(AtomicUsize::new(0)); state .renderer_access - .claim("current-renderer", &renderer_url) + .claim_for(&window_id, "current-renderer", &renderer_url) .unwrap(); let calls_for_navigation = Arc::clone(&calls); @@ -283,7 +341,7 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); state .renderer_access - .validate("current-renderer", &renderer_url) + .validate_for(&window_id, "current-renderer", &renderer_url) .unwrap(); } @@ -293,29 +351,32 @@ mod tests { let state = ClientState::initialize_at(directory.path()).unwrap(); let outgoing_url = url::Url::parse("http://127.0.0.1:43123/workspace").unwrap(); let incoming_url = url::Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + let window_id = state.active_window_id().unwrap(); state .renderer_access - .claim("outgoing-renderer", &outgoing_url) + .claim_for(&window_id, "outgoing-renderer", &outgoing_url) .unwrap(); execute_navigation(Some(&state), Some(&incoming_url), || Ok(())).unwrap(); state .renderer_access - .validate("outgoing-renderer", &outgoing_url) - .unwrap(); - assert!(state.renderer_access.allows_claim_origin(&outgoing_url)); - state - .renderer_access - .claim("incoming-renderer", &incoming_url) + .validate_for(&window_id, "outgoing-renderer", &outgoing_url) .unwrap(); assert!(state .renderer_access - .validate("outgoing-renderer", &outgoing_url) + .allows_claim_origin_for(&window_id, &outgoing_url)); + state + .renderer_access + .claim_for(&window_id, "incoming-renderer", &incoming_url) + .unwrap(); + assert!(state + .renderer_access + .validate_for(&window_id, "outgoing-renderer", &outgoing_url) .is_err()); state .renderer_access - .validate("incoming-renderer", &incoming_url) + .validate_for(&window_id, "incoming-renderer", &incoming_url) .unwrap(); } } diff --git a/packages/tauri-app/src-tauri/src/client_state/partitions.rs b/packages/tauri-app/src-tauri/src/client_state/partitions.rs new file mode 100644 index 00000000..9791e5e7 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/client_state/partitions.rs @@ -0,0 +1,324 @@ +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::fs; +#[cfg(not(windows))] +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub(super) const PROTOCOL_VERSION: u64 = 1; +pub(super) const MAX_ROOT_BYTES: usize = 1024 * 1024; +const MAX_PARTITION_BYTES: usize = 1024 * 1024; +const MAX_COMMIT_BYTES: usize = 8 * 1024 * 1024; +const MAX_PARTITION_KEYS: usize = 4096; +const PARTITION_DIRECTORY: &str = "partitions"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PartitionCommit { + protocol_version: Value, + pub(super) snapshot: Value, + partitions: HashMap, + partition_keys: Vec, +} + +pub(super) struct ValidatedCommit { + pub(super) snapshot: Value, + partitions: HashMap, + pub(super) partition_keys: Vec, +} + +pub(super) fn valid_key(key: &str) -> bool { + key.len() == 64 + && key + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +pub(super) fn validate_keys(value: &Value) -> Option> { + let values = value.as_array()?; + if values.len() > MAX_PARTITION_KEYS { + return None; + } + let mut keys: Vec = Vec::with_capacity(values.len()); + for value in values { + let key = value.as_str()?; + if !valid_key(key) || keys.last().is_some_and(|previous| previous.as_str() >= key) { + return None; + } + keys.push(key.to_string()); + } + Some(keys) +} + +pub(super) fn validate_root(value: &Value) -> Option> { + let root = value.as_object()?; + if super::envelope::exact_nonnegative_safe_integer(root.get("version")?) != Some(2) { + return None; + } + let session_partition = root.get("sessionPartition")?.as_str()?; + if !valid_key(session_partition) { + return None; + } + let partition_keys = validate_keys(root.get("partitionKeys")?)?; + partition_keys + .iter() + .any(|key| key == session_partition) + .then_some(partition_keys) +} + +impl PartitionCommit { + pub(super) fn validate(self) -> Result { + if super::envelope::exact_nonnegative_safe_integer(&self.protocol_version) + != Some(PROTOCOL_VERSION) + { + return Err("Unsupported client state partition protocol".to_string()); + } + if self.partition_keys.len() > MAX_PARTITION_KEYS + || self.partitions.len() > MAX_PARTITION_KEYS + { + return Err("Too many client state partitions".to_string()); + } + let partition_keys_value = Value::Array( + self.partition_keys + .iter() + .cloned() + .map(Value::String) + .collect(), + ); + let Some(payload_keys) = validate_keys(&partition_keys_value) else { + return Err("Invalid client state partition keys".to_string()); + }; + let Some(root_keys) = validate_root(&self.snapshot) else { + return Err("Invalid client state partition root".to_string()); + }; + if root_keys != payload_keys { + return Err("Client state root partition keys do not match the commit".to_string()); + } + let mut supplied_keys = self.partitions.keys().collect::>(); + supplied_keys.sort(); + if supplied_keys.len() != root_keys.len() + || supplied_keys + .iter() + .zip(&root_keys) + .any(|(supplied, expected)| supplied.as_str() != expected) + { + return Err("Client state partitions do not match the root".to_string()); + } + let root_size = serde_json::to_vec(&self.snapshot) + .map_err(|err| err.to_string())? + .len(); + if root_size > MAX_ROOT_BYTES { + return Err("Client state root exceeds the 1 MiB limit".to_string()); + } + let mut commit_size = root_size; + for (key, content) in &self.partitions { + if !valid_key(key) { + return Err("Invalid client state partition reference".to_string()); + } + if content.len() > MAX_PARTITION_BYTES { + return Err("Client state partition exceeds the 1 MiB limit".to_string()); + } + commit_size = commit_size.checked_add(content.len()).ok_or_else(|| { + "Client state partition commit exceeds the 8 MiB limit".to_string() + })?; + if commit_size > MAX_COMMIT_BYTES { + return Err("Client state partition commit exceeds the 8 MiB limit".to_string()); + } + if hex_digest(content.as_bytes()) != *key { + return Err("Client state partition digest mismatch".to_string()); + } + } + Ok(ValidatedCommit { + snapshot: self.snapshot, + partitions: self.partitions, + partition_keys: root_keys, + }) + } +} + +pub(super) struct PartitionStore { + directory: PathBuf, +} + +impl PartitionStore { + pub(super) fn new(root: &Path) -> Self { + Self { + directory: root.join(PARTITION_DIRECTORY), + } + } + + pub(super) fn prepare( + &self, + commit: &ValidatedCommit, + authority_valid: &dyn Fn() -> bool, + ) -> Result<(), String> { + fs::create_dir_all(&self.directory).map_err(|err| err.to_string())?; + self.validate_directory(false)?; + let mut published = false; + for (key, content) in &commit.partitions { + published |= self.write_immutable(key, content.as_bytes(), authority_valid)?; + } + if published { + self.validate_directory(false)?; + sync_directory(&self.directory)?; + authority(authority_valid)?; + } + for key in &commit.partition_keys { + let content = self.read_verified(key)?; + authority(authority_valid)?; + if content.is_none() { + return Err(format!("Missing client state partition {key}")); + } + } + Ok(()) + } + + pub(super) fn load( + &self, + key: &str, + authority_valid: &dyn Fn() -> bool, + ) -> Result, String> { + if !self.validate_directory(true)? { + return Ok(None); + } + let result = self.read_verified(key)?; + authority(authority_valid)?; + Ok(result) + } + + pub(super) fn sweep( + &self, + partition_keys: &[String], + authority_valid: &dyn Fn() -> bool, + ) -> Result<(), String> { + if !self.validate_directory(true)? { + return Ok(()); + } + let entries = match fs::read_dir(&self.directory) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err.to_string()), + }; + authority(authority_valid)?; + let retained = partition_keys + .iter() + .map(String::as_str) + .collect::>(); + for entry in entries { + let entry = entry.map_err(|err| err.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !valid_key(name) || retained.contains(name) { + continue; + } + let metadata = fs::symlink_metadata(entry.path()).map_err(|err| err.to_string())?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + continue; + } + fs::remove_file(entry.path()).map_err(|err| err.to_string())?; + authority(authority_valid)?; + } + Ok(()) + } + + fn validate_directory(&self, allow_missing: bool) -> Result { + match fs::symlink_metadata(&self.directory) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + Ok(true) + } + Ok(_) => Err("Invalid client state partition directory".to_string()), + Err(err) if allow_missing && err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err.to_string()), + } + } + + fn read_verified(&self, key: &str) -> Result, String> { + let path = self.directory.join(key); + match fs::symlink_metadata(&path) { + Ok(metadata) if !metadata.file_type().is_file() => return Ok(None), + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.to_string()), + } + match fs::read(path) { + Ok(bytes) if bytes.len() <= MAX_PARTITION_BYTES && hex_digest(&bytes) == key => { + String::from_utf8(bytes) + .map(Some) + .map_err(|err| err.to_string()) + } + Ok(_) => Ok(None), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.to_string()), + } + } + + fn write_immutable( + &self, + key: &str, + bytes: &[u8], + authority_valid: &dyn Fn() -> bool, + ) -> Result { + if self.read_verified(key)?.is_some() { + authority(authority_valid)?; + return Ok(false); + } + let path = self.directory.join(key); + match fs::symlink_metadata(&path) { + Ok(_) => return Err(format!("Invalid existing client state partition {key}")), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.to_string()), + } + let mut temporary = + tempfile::NamedTempFile::new_in(&self.directory).map_err(|err| err.to_string())?; + temporary + .write_all(bytes) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|err| err.to_string())?; + authority(authority_valid)?; + let published = match temporary.persist_noclobber(&path) { + Ok(_) => true, + Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => { + if self.read_verified(key)?.is_none() { + return Err(format!("Invalid existing client state partition {key}")); + } + false + } + Err(err) => return Err(err.error.to_string()), + }; + authority(authority_valid)?; + Ok(published) + } +} + +fn authority(authority_valid: &dyn Fn() -> bool) -> Result<(), String> { + if authority_valid() { + Ok(()) + } else { + Err("Client state authority changed during partition I/O".to_string()) + } +} + +fn hex_digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(not(windows))] +pub(super) fn sync_directory(path: &Path) -> Result<(), String> { + File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|err| err.to_string()) +} + +#[cfg(windows)] +pub(super) fn sync_directory(_path: &Path) -> Result<(), String> { + // Windows std::fs cannot open directories; synced files plus atomic publication are the stdlib limit. + Ok(()) +} diff --git a/packages/tauri-app/src-tauri/src/client_state/tests.rs b/packages/tauri-app/src-tauri/src/client_state/tests.rs index ce0324fc..5d039d7d 100644 --- a/packages/tauri-app/src-tauri/src/client_state/tests.rs +++ b/packages/tauri-app/src-tauri/src/client_state/tests.rs @@ -10,11 +10,32 @@ use super::{ }; use serde_json::{json, Value}; use std::fs; +use std::fs::OpenOptions; use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Barrier}; use std::thread; + +#[test] +fn legacy_migration_uuids_match_the_cross_host_exact_byte_vectors() { + for (content, expected) in [ + ( + br#"{"version":1}"#.as_slice(), + "2430f1a2-ad29-52d0-8678-85488a4c89e2", + ), + ( + br#"{ "version": 1, "restoreEnabled": false }"#.as_slice(), + "e6a1425a-ebb1-502f-b79c-d92772fa763b", + ), + ] { + assert_eq!( + super::envelope::deterministic_legacy_window_id(content), + expected + ); + assert_eq!(parse_client_state(content).active_window_id, expected); + } +} use std::time::Duration; use tempfile::TempDir; use url::Url; @@ -24,6 +45,7 @@ fn load(is_primary: bool, restore_enabled: bool, snapshot: Value) -> ClientState is_primary, restore_enabled, snapshot, + partition_protocol_version: Some(1), } } @@ -32,10 +54,15 @@ fn assert_access_rejected(state: &ClientState, token: &str, url: &Url) { } fn acknowledged_generation(state: &ClientState) -> u64 { + let window_id = state.active_window_id().unwrap(); state .renderer_flush - .acknowledged_generation - .load(Ordering::SeqCst) + .windows + .lock() + .unwrap() + .get(&window_id) + .map(|state| state.1) + .unwrap_or(0) } fn assert_receive_timeout(result: Result) { @@ -48,8 +75,8 @@ fn enable_restore(state: &ClientState) { fn enable_restore_in_memory(state: &ClientState) { let mut persisted = state.state.lock().unwrap(); - persisted.restore_enabled = true; - persisted.writes_enabled = true; + persisted.active_mut().restore_enabled = true; + persisted.active_mut().writes_enabled = true; } fn failing_state(initially_failing: bool) -> (TempDir, ClientState, Arc) { @@ -91,6 +118,7 @@ fn display(x: i32, y: i32, width: u32, height: u32) -> DisplayArea { y, width, height, + scale_factor: 1.0, } } fn concurrent_roles(path: &std::path::Path, count: usize) -> Vec { @@ -182,23 +210,67 @@ fn restore_defaults_on_unless_explicitly_disabled() { } #[test] -fn parses_envelopes_and_normalizes_zoom() { +fn parses_exact_envelopes_and_fences_malformed_values() { for bytes in [ br#"not json"#.as_slice(), br#"{"version":0,"restoreEnabled":false}"#.as_slice(), br#"{"version":1,"restoreEnabled":"no"}"#.as_slice(), + br#"{"version":1.5,"restoreEnabled":true}"#.as_slice(), + br#"{"version":"1","restoreEnabled":true}"#.as_slice(), + br#"{"version":1,"restoreEnabled":true,"protocolVersion":1}"#.as_slice(), ] { let state = parse_client_state(bytes); - assert!(state.restore_enabled); - assert_eq!(state.snapshot, None); - assert!(!state.unsupported_future_envelope); + assert!(!state.active().restore_enabled); + assert_eq!(state.active().snapshot, None); + assert!(state.unsupported_future_envelope); } let state = parse_client_state( - br#"{"version":1,"restoreEnabled":false,"snapshot":{"folder":"work"},"window":{"bounds":{"x":20,"y":30,"width":1400,"height":900},"maximized":true,"fullscreen":false,"zoomFactor":20}}"#, + br#"{"version":1,"restoreEnabled":false,"snapshot":{"folder":"work"},"window":{"bounds":{"x":20,"y":30,"width":1400,"height":900},"maximized":true,"fullscreen":false,"zoomFactor":5}}"#, + ); + assert!(!state.active().restore_enabled); + assert_eq!(state.active().snapshot, Some(json!({ "folder": "work" }))); + assert_eq!( + state.active().window.as_ref().unwrap().zoom_factor, + MAX_ZOOM_LEVEL + ); + let v1_with_partition_metadata = parse_client_state( + br#"{"version":1,"restoreEnabled":true,"snapshot":{},"protocolVersion":1,"partitionKeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, + ); + assert!(v1_with_partition_metadata.unsupported_future_envelope); + let key = "a".repeat(64); + let window_id = "11111111-1111-4111-8111-111111111111"; + let envelope_for = |version: &str| { + if version.starts_with('1') { + format!(r#"{{"version":{version},"restoreEnabled":true}}"#) + } else if version.starts_with('2') { + format!( + r#"{{"version":{version},"restoreEnabled":true,"snapshot":{{"version":2.0,"sessionPartition":"{key}","partitionKeys":["{key}"]}},"protocolVersion":1.0,"partitionKeys":["{key}"]}}"# + ) + } else { + format!( + r#"{{"version":{version},"activeWindowId":"{window_id}","windowOrder":["{window_id}"],"windows":{{"{window_id}":{{"restoreEnabled":true}}}}}}"# + ) + } + }; + for version in ["1", "1.0", "2", "2.0", "3", "3.0"] { + let envelope = envelope_for(version); + assert!(!parse_client_state(envelope.as_bytes()).unsupported_future_envelope); + } + for version in ["1.5", "2.5", "3.5"] { + let envelope = envelope_for(version); + assert!(parse_client_state(envelope.as_bytes()).unsupported_future_envelope); + } + assert!(parse_client_state( + format!(r#"{{"version":2,"restoreEnabled":true,"snapshot":{{"version":2,"sessionPartition":"{key}","partitionKeys":["{key}"]}},"protocolVersion":1.5,"partitionKeys":["{key}"]}}"#).as_bytes(), + ).unsupported_future_envelope); + assert_eq!( + super::envelope::exact_nonnegative_safe_integer(&json!(9_007_199_254_740_991_u64)), + Some(9_007_199_254_740_991) + ); + assert_eq!( + super::envelope::exact_nonnegative_safe_integer(&json!(9_007_199_254_740_992_u64)), + None ); - assert!(!state.restore_enabled); - assert_eq!(state.snapshot, Some(json!({ "folder": "work" }))); - assert_eq!(state.window.unwrap().zoom_factor, MAX_ZOOM_LEVEL); for (input, expected) in [ (1.25, Some(1.25)), (0.01, Some(0.25)), @@ -256,7 +328,11 @@ fn migrates_dual_legacy_files_with_disabled_dominance_and_malformed_fallback() { ) .unwrap(); assert_eq!(state.load().unwrap(), load(true, false, Value::Null)); - assert!(!parse_client_state(&fs::read(&shared).unwrap()).restore_enabled); + assert!( + !parse_client_state(&fs::read(&shared).unwrap()) + .active() + .restore_enabled + ); assert!(electron.join(CLIENT_STATE_FILENAME).exists()); assert!(tauri.join(CLIENT_STATE_FILENAME).exists()); } @@ -307,7 +383,7 @@ fn v1_shared_state_is_copied_once_and_v2_mutations_remain_isolated() { fs::create_dir_all(&shared).unwrap(); fs::create_dir_all(&tauri).unwrap(); let legacy_bytes = br#"{ - "version": 1, "restoreEnabled": true, "snapshot": { "source": "v1" }, "v1Only": true + "version": 1, "restoreEnabled": true, "snapshot": { "source": "v1" } }"#; fs::write(&legacy_shared, legacy_bytes).unwrap(); let host_local = br#"{"version":1,"restoreEnabled":true,"snapshot":{"source":"host-local"}}"#; @@ -350,6 +426,42 @@ fn v1_shared_state_is_copied_once_and_v2_mutations_remain_isolated() { assert_eq!(fs::read(&legacy_shared).unwrap(), legacy_bytes); } +#[test] +fn unshipped_partitioned_v2_shared_state_is_not_copied_over_shipped_v1_migration() { + let root = tempfile::tempdir().unwrap(); + let tauri = root.path().join("tauri"); + let shared = root.path().join("shared"); + let legacy_shared = shared.join(CLIENT_STATE_FILENAME); + let v2 = shared.join("v2"); + let v2_state = v2.join(CLIENT_STATE_FILENAME); + fs::create_dir_all(&shared).unwrap(); + fs::create_dir_all(&tauri).unwrap(); + fs::write(&legacy_shared, br#"{"version":2,"restoreEnabled":true}"#).unwrap(); + fs::write( + tauri.join(CLIENT_STATE_FILENAME), + br#"{"version":1,"restoreEnabled":true,"snapshot":{"source":"shipped-v1"}}"#, + ) + .unwrap(); + + let state = ClientState::initialize_at_with_writer_and_election( + &tauri, + &v2.join("election"), + &v2_state, + Some(&legacy_shared), + None, + Arc::new(super::write_atomically), + ) + .unwrap(); + assert_eq!( + state.load().unwrap().snapshot, + json!({ "source": "shipped-v1" }) + ); + assert_eq!( + serde_json::from_slice::(&fs::read(v2_state).unwrap()).unwrap()["version"], + 1 + ); +} + #[test] fn electron_and_tauri_share_the_complete_envelope_across_handoffs() { let root = tempfile::tempdir().unwrap(); @@ -407,6 +519,18 @@ fn normalizes_window_bounds_against_displays() { } } #[test] +fn mixed_dpi_restore_selects_displays_in_physical_coordinates() { + let mut high_dpi = display(1920, 0, 2560, 1440); + high_dpi.scale_factor = 2.0; + assert_eq!( + clamp_window_bounds( + &bounds(1000, 500, 700, 600), + &[display(0, 0, 1920, 1080), high_dpi], + ), + Some(bounds(1000, 120, 800, 600)) + ); +} +#[test] fn stale_files_recover_without_trusting_pid_identity() { for lock_contents in [ b"{\"pid\":999999}".as_slice(), @@ -460,7 +584,7 @@ fn secondary_and_failed_initialization_are_isolated() { let state_path = directory.path().join(CLIENT_STATE_FILENAME); let original = fs::read(&state_path).unwrap(); let secondary = ClientState::initialize_at(directory.path()).unwrap(); - assert_eq!(secondary.load().unwrap(), load(false, true, Value::Null)); + assert_eq!(secondary.load().unwrap(), load(false, false, Value::Null)); assert!(!secondary.save_snapshot(json!({ "replace": true })).unwrap()); assert!(!secondary.set_restore_enabled(false).unwrap()); assert!(!secondary.clear().unwrap()); @@ -478,19 +602,23 @@ fn disable_and_clear_suppress_later_writes() { let state = ClientState::initialize_at(directory.path()).unwrap(); enable_restore(&state); assert!(state.save_snapshot(json!({ "removed": true })).unwrap()); - state.state.lock().unwrap().window = Some(window()); + state.state.lock().unwrap().active_mut().window = Some(window()); if clear { assert!(state.clear().unwrap()); assert_eq!(state.load().unwrap(), load(true, true, Value::Null)); } else { assert!(state.set_restore_enabled(false).unwrap()); assert_eq!(state.load().unwrap(), load(true, false, Value::Null)); - assert_eq!(*state.zoom_level.lock().unwrap(), DEFAULT_ZOOM_LEVEL); + let window_id = state.active_window_id().unwrap(); + assert_eq!( + state.zoom_levels.lock().unwrap()[&window_id], + DEFAULT_ZOOM_LEVEL + ); } let path = directory.path().join(CLIENT_STATE_FILENAME); let persisted = parse_client_state(&fs::read(&path).unwrap()); - assert_eq!(persisted.snapshot, None); - assert_eq!(persisted.window, None); + assert_eq!(persisted.active().snapshot, None); + assert_eq!(persisted.active().window, None); let bytes = fs::read(&path).unwrap(); assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); assert_eq!(fs::read(path).unwrap(), bytes); @@ -502,7 +630,7 @@ fn failed_writes_restore_memory_and_suppression_state() { let (_directory, state, fail) = failing_state(false); enable_restore(&state); assert!(state.save_snapshot(json!({ "kept": true })).unwrap()); - state.state.lock().unwrap().window = Some(window()); + state.state.lock().unwrap().active_mut().window = Some(window()); fail.store(true, Ordering::SeqCst); let error = match operation { "snapshot" => state.save_snapshot(json!({ "lost": true })).unwrap_err(), @@ -514,8 +642,8 @@ fn failed_writes_restore_memory_and_suppression_state() { state.load().unwrap(), load(true, true, json!({ "kept": true })) ); - assert!(state.state.lock().unwrap().window.is_some()); - assert!(state.state.lock().unwrap().writes_enabled); + assert!(state.state.lock().unwrap().active().window.is_some()); + assert!(state.state.lock().unwrap().active().writes_enabled); fail.store(false, Ordering::SeqCst); assert!(state.save_snapshot(json!({ "replacement": true })).unwrap()); } @@ -615,6 +743,40 @@ fn renderer_tokens_and_origins_are_isolated_across_navigation() { } } +#[test] +fn accepted_page_load_stages_renderer_rotation_and_preserves_generation_fencing() { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + let window_id = state.active_window_id().unwrap(); + let url = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + state + .renderer_access + .claim_for(&window_id, "outgoing", &url) + .unwrap(); + let generation = state + .renderer_access + .validate_for(&window_id, "outgoing", &url) + .unwrap(); + + state.stage_renderer_page_load(&window_id, &url).unwrap(); + state + .renderer_access + .claim_for(&window_id, "incoming", &url) + .unwrap(); + + assert!(!state + .renderer_access + .is_generation_current_for(&window_id, generation)); + assert!(state + .renderer_access + .validate_for(&window_id, "outgoing", &url) + .is_err()); + state + .renderer_access + .validate_for(&window_id, "incoming", &url) + .unwrap(); +} + #[test] fn reload_preserves_pending_cross_origin_authority_until_incoming_claim() { let directory = tempfile::tempdir().unwrap(); @@ -665,13 +827,16 @@ fn failed_follow_up_navigation_restores_previous_pending_authority() { fn flush_generation_and_request_order_are_strict() { let directory = tempfile::tempdir().unwrap(); let state = Arc::new(ClientState::initialize_at(directory.path()).unwrap()); + let window_id = state.active_window_id().unwrap(); state .renderer_flush - .next_generation - .store(2, Ordering::SeqCst); - state.acknowledge_renderer_flush(1); + .windows + .lock() + .unwrap() + .insert(window_id.clone(), (2, 0)); + state.acknowledge_renderer_flush(&window_id, 1); assert_eq!(acknowledged_generation(&state), 0); - state.acknowledge_renderer_flush(2); + state.acknowledge_renderer_flush(&window_id, 2); assert_eq!(acknowledged_generation(&state), 2); let first = state.renderer_flush.request_lock.lock().unwrap(); let waiting = Arc::clone(&state); @@ -706,6 +871,13 @@ fn ownership_release_drains_active_write_and_blocks_later_writes() { let writing = Arc::clone(&state); let writer = thread::spawn(move || writing.save_snapshot(json!({ "first": true }))); started_rx.recv().unwrap(); + let lock_path = directory.path().join(PRIMARY_LOCK_FILENAME); + let contender = OpenOptions::new() + .read(true) + .write(true) + .open(lock_path) + .unwrap(); + assert!(fs2::FileExt::try_lock_exclusive(&contender).is_err()); let releasing = Arc::clone(&state); let (released_tx, released_rx) = mpsc::channel(); let releaser = thread::spawn(move || { @@ -713,10 +885,13 @@ fn ownership_release_drains_active_write_and_blocks_later_writes() { released_tx.send(()).unwrap(); }); assert_receive_timeout(released_rx.recv_timeout(Duration::from_millis(50))); + assert!(fs2::FileExt::try_lock_exclusive(&contender).is_err()); allow_tx.send(()).unwrap(); assert!(writer.join().unwrap().unwrap()); released_rx.recv().unwrap(); releaser.join().unwrap(); + fs2::FileExt::try_lock_exclusive(&contender).unwrap(); + fs2::FileExt::unlock(&contender).unwrap(); assert!(!state.is_primary()); assert!(!state.save_snapshot(json!({ "tooLate": true })).unwrap()); } @@ -743,7 +918,7 @@ fn ownership_loss_blocks_the_final_atomic_replacement() { "Client state ownership changed before atomic replacement" ); assert!(!state.is_primary()); - assert_eq!(state.load().unwrap(), load(false, true, Value::Null)); + assert_eq!(state.load().unwrap(), load(false, false, Value::Null)); assert!(!directory.path().join(CLIENT_STATE_FILENAME).exists()); } @@ -798,3 +973,462 @@ fn oversized_snapshot_does_not_replace_state() { .is_err()); assert_eq!(state.load().unwrap().snapshot, json!({ "small": true })); } + +fn partition_commit(value: Value) -> super::partitions::PartitionCommit { + serde_json::from_value(value).unwrap() +} + +fn partition_key(content: &str) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(content.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn partition_root(keys: &[String], fields: Value) -> Value { + let Value::Object(mut root) = fields else { + panic!("partition root fields must be an object"); + }; + root.insert("version".to_string(), json!(2)); + root.insert("sessionPartition".to_string(), json!(keys[0])); + root.insert("partitionKeys".to_string(), json!(keys)); + Value::Object(root) +} + +#[test] +fn v1_and_v2_migrate_in_memory_without_load_rewrites() { + let content = "legacy partition"; + let key = partition_key(content); + for initial in [ + json!({ "version": 1, "restoreEnabled": true, "snapshot": { "legacy": 1 } }), + json!({ + "version": 2, + "restoreEnabled": true, + "snapshot": partition_root(std::slice::from_ref(&key), json!({})), + "protocolVersion": 1, + "partitionKeys": [key.clone()] + }), + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let original = serde_json::to_vec(&initial).unwrap(); + fs::write(&path, &original).unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + assert_eq!(fs::read(&path).unwrap(), original); + assert!(state.save_snapshot(json!({ "mutated": true })).unwrap()); + let persisted: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + assert_eq!(persisted["version"], 3); + assert_eq!(persisted["windowOrder"][0], persisted["activeWindowId"]); + } +} + +#[test] +fn v3_records_isolate_tokens_partitions_clear_and_removal() { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&state); + let window_a = state.active_window_id().unwrap(); + let window_b = "11111111-1111-4111-8111-111111111111".to_string(); + assert!(state.add_window(window_b.clone()).unwrap()); + + let url = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + state + .renderer_access + .claim_for(&window_a, "token-a", &url) + .unwrap(); + state + .renderer_access + .claim_for(&window_b, "token-b", &url) + .unwrap(); + assert!(state + .renderer_access + .validate_for(&window_b, "token-a", &url) + .is_err()); + assert!(state + .save_snapshot_guarded_for(&window_a, json!({ "window": "a" }), || true) + .unwrap()); + assert!(state + .save_snapshot_guarded_for(&window_b, json!({ "window": "b" }), || true) + .unwrap()); + state + .state + .lock() + .unwrap() + .record_mut(&window_a) + .unwrap() + .window = Some(window()); + state + .state + .lock() + .unwrap() + .record_mut(&window_b) + .unwrap() + .window = Some(NativeWindowState { + bounds: bounds(40, 50, 1000, 700), + maximized: false, + fullscreen: true, + zoom_factor: 1.25, + }); + assert_eq!( + state.load_window(&window_a).unwrap().snapshot, + json!({ "window": "a" }) + ); + assert_eq!( + state.load_window(&window_b).unwrap().snapshot, + json!({ "window": "b" }) + ); + + let content_a = "partition a"; + let key_a = partition_key(content_a); + let content_b = "partition b"; + let key_b = partition_key(content_b); + assert!(state + .commit_partitions_guarded_for( + &window_a, + partition_commit(json!({ + "protocolVersion": 1, + "snapshot": partition_root(std::slice::from_ref(&key_a), json!({})), + "partitions": { key_a.clone(): content_a }, + "partitionKeys": [key_a.clone()] + })), + || true, + ) + .unwrap()); + assert!(state + .commit_partitions_guarded_for( + &window_b, + partition_commit(json!({ + "protocolVersion": 1, + "snapshot": partition_root(std::slice::from_ref(&key_b), json!({})), + "partitions": { key_b.clone(): content_b }, + "partitionKeys": [key_b.clone()] + })), + || true, + ) + .unwrap()); + assert_eq!( + state + .load_partition_guarded_for(&window_a, &key_b, || true) + .unwrap(), + None + ); + assert!(state.clear_guarded(&window_a, || true).unwrap()); + assert!(!directory.path().join("partitions").join(key_a).exists()); + assert!(directory.path().join("partitions").join(&key_b).exists()); + assert_eq!( + state + .load_partition_guarded_for(&window_b, &key_b, || true) + .unwrap() + .as_deref(), + Some(content_b) + ); + + assert!(state.remove_window(&window_a).unwrap()); + let persisted: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + assert_eq!(persisted["activeWindowId"], window_b); + assert_eq!(persisted["windowOrder"], json!([window_b])); + assert!(state.remove_window(&window_b).unwrap()); + assert!(state.window_ids().is_empty()); + let persisted: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + assert_eq!(persisted["activeWindowId"], window_b); + assert_eq!(persisted["windowOrder"], json!([])); + assert_eq!(persisted["windows"], json!({})); + assert!(!directory.path().join("partitions").join(key_b).exists()); +} + +#[test] +fn invalid_v3_is_frozen_until_explicit_clear() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let window_id = "11111111-1111-4111-8111-111111111111"; + let original = format!( + " {{ \"version\": 3, \"activeWindowId\": \"{window_id}\", \"windowOrder\": [\"{window_id}\"], \"windows\": {{ \"{window_id}\": {{ \"restoreEnabled\": true, \"unknown\": 1 }} }} }} " + ); + fs::write(&path, original.as_bytes()).unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + assert!(state.clear().unwrap()); + let persisted: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + assert_eq!(persisted["version"], 3); +} + +#[test] +fn partition_protocol_and_hashes_are_validated() { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&state); + let content = "partition"; + let key = partition_key(content); + for payload in [ + json!({ "protocolVersion": 2, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): content }, "partitionKeys": [key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): "wrong" }, "partitionKeys": [key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): content }, "partitionKeys": [key.clone(), key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": {}, "partitionKeys": [key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): content, partition_key("extra"): "extra" }, "partitionKeys": [key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): content }, "partitionKeys": [partition_key("other")] }), + ] { + assert!(state + .commit_partitions_guarded(partition_commit(payload), || true) + .is_err()); + } + + let mut partitions = serde_json::Map::new(); + let mut partition_keys = Vec::new(); + for index in 0..8 { + let content = format!("{index}{}", "x".repeat(1024 * 1024 - 1)); + let key = partition_key(&content); + partitions.insert(key.clone(), Value::String(content)); + partition_keys.push(key); + } + partition_keys.sort(); + let error = state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, "snapshot": partition_root(&partition_keys, json!({})), + "partitions": partitions, "partitionKeys": partition_keys + })), + || true, + ) + .unwrap_err(); + assert!(error.contains("8 MiB")); +} + +#[test] +fn partition_commit_read_and_failure_safety() { + let (directory, state, fail) = failing_state(false); + enable_restore(&state); + let old_content = "old partition"; + let old_key = partition_key(old_content); + assert!(state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&old_key), json!({ "root": "old" })), + "partitions": { old_key.clone(): old_content }, "partitionKeys": [old_key.clone()] + })), + || true, + ) + .unwrap()); + let root: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + let window_id = root["activeWindowId"].as_str().unwrap(); + assert_eq!(root["version"], 3); + assert_eq!(root["windows"][window_id]["partitionProtocolVersion"], 1); + assert!(root["windows"][window_id].get("protocolVersion").is_none()); + assert_eq!( + state + .load_partition_guarded(&old_key, || true) + .unwrap() + .as_deref(), + Some(old_content) + ); + + let next_content = "next partition"; + let next_key = partition_key(next_content); + fail.store(true, Ordering::SeqCst); + assert!(state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&next_key), json!({ "root": "next" })), + "partitions": { next_key.clone(): next_content }, "partitionKeys": [next_key.clone()] + })), + || true, + ) + .is_err()); + assert_eq!( + state.load().unwrap().snapshot, + partition_root(std::slice::from_ref(&old_key), json!({ "root": "old" })) + ); + assert!(directory.path().join("partitions").join(&old_key).exists()); + assert!(directory.path().join("partitions").join(&next_key).exists()); + fail.store(false, Ordering::SeqCst); + assert!(state.clear().unwrap()); + assert!(!directory.path().join("partitions").join(&old_key).exists()); + assert!(!directory.path().join("partitions").join(&next_key).exists()); +} + +#[test] +fn v2_window_persistence_and_partition_to_monolithic_cleanup() { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&state); + let content = "window partition"; + let key = partition_key(content); + assert!(state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({ "root": true })), + "partitions": { key.clone(): content }, "partitionKeys": [key.clone()] + })), + || true, + ) + .unwrap()); + state.state.lock().unwrap().active_mut().window = Some(window()); + state.flush().unwrap(); + let root: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + let window_id = root["activeWindowId"].as_str().unwrap(); + assert_eq!(root["version"], 3); + assert_eq!(root["windows"][window_id]["partitionProtocolVersion"], 1); + assert_eq!( + root["windows"][window_id]["partitionKeys"], + json!([key.clone()]) + ); + state.release_locks(); + + let restarted = ClientState::initialize_at(directory.path()).unwrap(); + assert_eq!( + restarted.state.lock().unwrap().active().window, + Some(window()) + ); + assert_eq!( + restarted + .load_partition_guarded(&key, || true) + .unwrap() + .as_deref(), + Some(content) + ); + assert!(restarted + .save_snapshot(json!({ "monolithic": true })) + .unwrap()); + let root: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + let window_id = root["activeWindowId"].as_str().unwrap(); + assert_eq!(root["version"], 3); + assert!(root["windows"][window_id] + .get("partitionProtocolVersion") + .is_none()); + assert!(!directory.path().join("partitions").join(key).exists()); +} + +#[test] +fn malformed_and_future_v2_roots_fence_writes_and_gc_until_clear() { + let content = "keep orphan"; + let key = partition_key(content); + for root in [ + json!({ "version": 2, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 2, "partitionKeys": [key.clone()] }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 1, "partitionKeys": [key.clone(), key.clone()] }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 1, "partitionKeys": [key.to_uppercase()] }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": "x".repeat(1024 * 1024), "protocolVersion": 1, "partitionKeys": [key.clone()] }), + json!({ "version": 2, "restoreEnabled": "yes", "snapshot": {}, "protocolVersion": 1, "partitionKeys": [key.clone()] }), + json!({ "version": 3, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 1, "partitionKeys": [key.clone()] }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "protocolVersion": 1, "partitionKeys": [key.clone()], "extra": true }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "protocolVersion": 1, "partitionKeys": [partition_key("other")] }), + json!({ "version": 1, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 1 }), + json!({ "version": 1.5, "restoreEnabled": true }), + json!({ "version": "2", "restoreEnabled": true }), + json!("not an envelope"), + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let original = serde_json::to_vec(&root).unwrap(); + fs::write(&path, &original).unwrap(); + fs::create_dir(directory.path().join("partitions")).unwrap(); + let partition_path = directory.path().join("partitions").join(&key); + fs::write(&partition_path, content).unwrap(); + let writes = Arc::new(AtomicUsize::new(0)); + let writer_writes = Arc::clone(&writes); + let state = ClientState::initialize_at_with_writer( + directory.path(), + Arc::new(move |path, bytes, ownership_valid| { + writer_writes.fetch_add(1, Ordering::SeqCst); + super::write_atomically(path, bytes, ownership_valid) + }), + ) + .unwrap(); + assert_eq!(state.load().unwrap(), load(true, false, Value::Null)); + assert_eq!(state.load_partition_guarded(&key, || true).unwrap(), None); + assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); + assert!(!state.set_restore_enabled(false).unwrap()); + state.flush().unwrap(); + assert_eq!(writes.load(Ordering::SeqCst), 0); + assert_eq!(fs::read(&path).unwrap(), original); + assert!(partition_path.exists()); + + assert!(state.clear().unwrap()); + assert_eq!(writes.load(Ordering::SeqCst), 1); + assert_eq!( + serde_json::from_slice::(&fs::read(path).unwrap()).unwrap()["version"], + 3 + ); + assert!(!partition_path.exists()); + } + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let original = b"{not json"; + fs::write(&path, original).unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); + state.flush().unwrap(); + assert_eq!(fs::read(path).unwrap(), original); +} + +#[test] +fn partition_directory_and_gc_reject_unsafe_entries() { + let occupied = tempfile::tempdir().unwrap(); + let occupied_state = ClientState::initialize_at(occupied.path()).unwrap(); + enable_restore(&occupied_state); + fs::write(occupied.path().join("partitions"), "not a directory").unwrap(); + let occupied_content = "occupied"; + let occupied_key = partition_key(occupied_content); + assert!(occupied_state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, + "snapshot": partition_root(std::slice::from_ref(&occupied_key), json!({})), + "partitions": { occupied_key.clone(): occupied_content }, + "partitionKeys": [occupied_key] + })), + || true, + ) + .is_err()); + + let gc = tempfile::tempdir().unwrap(); + let gc_state = ClientState::initialize_at(gc.path()).unwrap(); + enable_restore(&gc_state); + let partitions = gc.path().join("partitions"); + let removable = partition_key("orphan"); + fs::create_dir(&partitions).unwrap(); + fs::write(partitions.join(&removable), "orphan").unwrap(); + fs::write(partitions.join("unrelated.txt"), "keep").unwrap(); + fs::create_dir(partitions.join("f".repeat(64))).unwrap(); + assert!(gc_state.clear().unwrap()); + assert!(!partitions.join(removable).exists()); + assert!(partitions.join("unrelated.txt").exists()); + assert!(partitions.join("f".repeat(64)).exists()); +} + +#[cfg(unix)] +#[test] +fn partition_directory_symlink_is_rejected() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&state); + let target = directory.path().join("partition-target"); + fs::create_dir(&target).unwrap(); + symlink(target, directory.path().join("partitions")).unwrap(); + let content = "linked"; + let key = partition_key(content); + assert!(state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, + "snapshot": partition_root(std::slice::from_ref(&key), json!({})), + "partitions": { key.clone(): content }, + "partitionKeys": [key] + })), + || true, + ) + .is_err()); +} diff --git a/packages/tauri-app/src-tauri/src/client_state/window.rs b/packages/tauri-app/src-tauri/src/client_state/window.rs index fbcad4f8..4d6bc662 100644 --- a/packages/tauri-app/src-tauri/src/client_state/window.rs +++ b/packages/tauri-app/src-tauri/src/client_state/window.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::atomic::Ordering; use std::time::Duration; -use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, WindowEvent}; +use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, WindowEvent}; const MIN_WINDOW_WIDTH: i32 = 800; const MIN_WINDOW_HEIGHT: i32 = 600; @@ -37,20 +37,47 @@ pub(super) struct DisplayArea { pub(super) y: i32, pub(super) width: u32, pub(super) height: u32, + pub(super) scale_factor: f64, +} + +struct ClampedBounds { + logical: WindowBounds, + physical: WindowBounds, } pub(super) fn normalize_window_state(value: &Value) -> Option { let value = value.as_object()?; + if value.len() != 4 + || !["bounds", "maximized", "fullscreen", "zoomFactor"] + .iter() + .all(|key| value.contains_key(*key)) + { + return None; + } + let bounds_value = value.get("bounds")?.as_object()?; + if bounds_value.len() != 4 + || !["x", "y", "width", "height"] + .iter() + .all(|key| bounds_value.contains_key(*key)) + { + return None; + } let bounds: WindowBounds = serde_json::from_value(value.get("bounds")?.clone()).ok()?; if bounds.width <= 0 || bounds.height <= 0 { return None; } + let maximized = value.get("maximized")?.as_bool()?; + let fullscreen = value.get("fullscreen")?.as_bool()?; + let zoom_factor = value.get("zoomFactor")?.as_f64()?; + if !zoom_factor.is_finite() || !(MIN_ZOOM_LEVEL..=MAX_ZOOM_LEVEL).contains(&zoom_factor) { + return None; + } Some(NativeWindowState { bounds, - maximized: value.get("maximized").and_then(Value::as_bool) == Some(true), - fullscreen: value.get("fullscreen").and_then(Value::as_bool) == Some(true), - zoom_factor: normalize_zoom_level(value.get("zoomFactor").and_then(Value::as_f64)), + maximized, + fullscreen, + zoom_factor, }) } @@ -71,10 +98,22 @@ pub(super) fn clamp_window_bounds( bounds: &WindowBounds, displays: &[DisplayArea], ) -> Option { + clamp_window_bounds_for_restore(bounds, displays).map(|bounds| bounds.logical) +} + +fn clamp_window_bounds_for_restore( + bounds: &WindowBounds, + displays: &[DisplayArea], +) -> Option { let display = displays .iter() .copied() - .filter(|display| display.width > 0 && display.height > 0) + .filter(|display| { + display.width > 0 + && display.height > 0 + && display.scale_factor.is_finite() + && display.scale_factor > 0.0 + }) .reduce(|best, candidate| { let best_intersection = intersection_area(bounds, best); let candidate_intersection = intersection_area(bounds, candidate); @@ -88,46 +127,63 @@ pub(super) fn clamp_window_bounds( best } })?; + let scale = display.scale_factor; let maximum_width = display.width.min(i32::MAX as u32) as i32; let maximum_height = display.height.min(i32::MAX as u32) as i32; - let width = bounds - .width - .clamp(MIN_WINDOW_WIDTH.min(maximum_width), maximum_width); - let height = bounds - .height - .clamp(MIN_WINDOW_HEIGHT.min(maximum_height), maximum_height); + let requested_width = (f64::from(bounds.width) * scale).round() as i32; + let requested_height = (f64::from(bounds.height) * scale).round() as i32; + let minimum_width = (f64::from(MIN_WINDOW_WIDTH) * scale).round() as i32; + let minimum_height = (f64::from(MIN_WINDOW_HEIGHT) * scale).round() as i32; + let width = requested_width.clamp(minimum_width.min(maximum_width), maximum_width); + let height = requested_height.clamp(minimum_height.min(maximum_height), maximum_height); let minimum_x = i64::from(display.x); let minimum_y = i64::from(display.y); let maximum_x = (minimum_x + i64::from(maximum_width - width)).min(i64::from(i32::MAX)); let maximum_y = (minimum_y + i64::from(maximum_height - height)).min(i64::from(i32::MAX)); - Some(WindowBounds { - x: i64::from(bounds.x).clamp(minimum_x, maximum_x) as i32, - y: i64::from(bounds.y).clamp(minimum_y, maximum_y) as i32, + let physical = WindowBounds { + x: ((f64::from(bounds.x) * scale).round() as i64).clamp(minimum_x, maximum_x) as i32, + y: ((f64::from(bounds.y) * scale).round() as i64).clamp(minimum_y, maximum_y) as i32, width, height, + }; + Some(ClampedBounds { + logical: WindowBounds { + x: (f64::from(physical.x) / scale).round() as i32, + y: (f64::from(physical.y) / scale).round() as i32, + width: (f64::from(physical.width) / scale).round() as i32, + height: (f64::from(physical.height) / scale).round() as i32, + }, + physical, }) } fn intersection_area(bounds: &WindowBounds, display: DisplayArea) -> i64 { - let left = i64::from(bounds.x).max(i64::from(display.x)); - let top = i64::from(bounds.y).max(i64::from(display.y)); - let right = (i64::from(bounds.x) + i64::from(bounds.width)) - .min(i64::from(display.x) + i64::from(display.width)); - let bottom = (i64::from(bounds.y) + i64::from(bounds.height)) - .min(i64::from(display.y) + i64::from(display.height)); + let scale = display.scale_factor; + let x = (f64::from(bounds.x) * scale).round() as i64; + let y = (f64::from(bounds.y) * scale).round() as i64; + let width = (f64::from(bounds.width) * scale).round() as i64; + let height = (f64::from(bounds.height) * scale).round() as i64; + let left = x.max(i64::from(display.x)); + let top = y.max(i64::from(display.y)); + let right = (x + width).min(i64::from(display.x) + i64::from(display.width)); + let bottom = (y + height).min(i64::from(display.y) + i64::from(display.height)); (right - left).max(0) * (bottom - top).max(0) } fn center_distance_squared(bounds: &WindowBounds, display: DisplayArea) -> i128 { - let bounds_x = i128::from(bounds.x) * 2 + i128::from(bounds.width); - let bounds_y = i128::from(bounds.y) * 2 + i128::from(bounds.height); + let scale = display.scale_factor; + let bounds_x = ((f64::from(bounds.x) * 2.0 + f64::from(bounds.width)) * scale).round() as i128; + let bounds_y = ((f64::from(bounds.y) * 2.0 + f64::from(bounds.height)) * scale).round() as i128; let display_x = i128::from(display.x) * 2 + i128::from(display.width); let display_y = i128::from(display.y) * 2 + i128::from(display.height); (bounds_x - display_x).pow(2) + (bounds_y - display_y).pow(2) } -fn capture_main_window_in_memory(app: &AppHandle) { +fn capture_window_in_memory(app: &AppHandle, window_label: &str, window_id: &str, persisted: bool) { + if !persisted { + return; + } let Some(client_state) = app.try_state::() else { return; }; @@ -137,10 +193,13 @@ fn capture_main_window_in_memory(app: &AppHandle) { if !client_state.is_primary() { return; } - if client_state.normal_writes_suppressed().unwrap_or(true) { + if client_state + .normal_writes_suppressed(window_id) + .unwrap_or(true) + { return; } - let Some(window) = app.get_webview_window("main") else { + let Some(window) = app.get_webview_window(window_label) else { return; }; @@ -168,17 +227,21 @@ fn capture_main_window_in_memory(app: &AppHandle) { None }; let zoom_factor = client_state - .zoom_level + .zoom_levels .lock() - .map(|zoom| *zoom) + .ok() + .and_then(|zoom| zoom.get(window_id).copied()) .unwrap_or(DEFAULT_ZOOM_LEVEL); let Ok(mut state) = client_state.state.lock() else { return; }; + let Ok(record) = state.record_mut(window_id) else { + return; + }; let bounds = - current_bounds.or_else(|| state.window.as_ref().map(|window| window.bounds.clone())); + current_bounds.or_else(|| record.window.as_ref().map(|window| window.bounds.clone())); if let Some(bounds) = bounds { - state.window = Some(NativeWindowState { + record.window = Some(NativeWindowState { bounds, maximized, fullscreen, @@ -207,10 +270,16 @@ fn schedule_flush(app: &AppHandle) { } #[cfg(windows)] -fn register_native_zoom_handler(window: &tauri::WebviewWindow, app: &AppHandle) { +fn register_native_zoom_handler( + window: &tauri::WebviewWindow, + app: &AppHandle, + window_id: String, + persisted: bool, +) { use webview2_com::ZoomFactorChangedEventHandler; let app = app.clone(); + let window_label = window.label().to_string(); if let Err(err) = window.with_webview(move |webview| { let controller = webview.controller(); let callback_app = app.clone(); @@ -230,16 +299,16 @@ fn register_native_zoom_handler(window: &tauri::WebviewWindow, app: &AppHandle) let Some(client_state) = callback_app.try_state::() else { return Ok(()); }; - let Ok(mut zoom_level) = client_state.zoom_level.lock() else { + let Ok(mut zoom_levels) = client_state.zoom_levels.lock() else { return Ok(()); }; - *zoom_level = normalized; - drop(zoom_level); + zoom_levels.insert(window_id.clone(), normalized); + drop(zoom_levels); if client_state.is_primary() - && client_state.normal_writes_suppressed().ok() == Some(false) + && client_state.normal_writes_suppressed(&window_id).ok() == Some(false) { - capture_main_window_in_memory(&callback_app); + capture_window_in_memory(&callback_app, &window_label, &window_id, persisted); schedule_flush(&callback_app); } Ok(()) @@ -253,53 +322,62 @@ fn register_native_zoom_handler(window: &tauri::WebviewWindow, app: &AppHandle) } } -pub fn setup_main_window(app: &AppHandle) -> Result<(), String> { +pub fn setup_local_window( + app: &AppHandle, + window: &tauri::WebviewWindow, + window_id: &str, + persisted: bool, +) -> Result<(), String> { let client_state = app.state::(); - let window = app - .get_webview_window("main") - .ok_or_else(|| "main window was not created".to_string())?; + if !persisted { + client_state.register_ephemeral_window(window_id.to_string()); + } let initial_zoom = client_state - .zoom_level + .zoom_levels .lock() - .map(|zoom| *zoom) + .ok() + .and_then(|zoom| zoom.get(window_id).copied()) .unwrap_or(DEFAULT_ZOOM_LEVEL); let _ = window.set_zoom(initial_zoom); #[cfg(windows)] - register_native_zoom_handler(&window, app); - if !client_state.is_primary() { + register_native_zoom_handler(window, app, window_id.to_string(), persisted); + if !client_state.is_primary() || !persisted { let _ = window.show(); return Ok(()); } let saved_window = { let state = client_state.state.lock().map_err(|err| err.to_string())?; - state + let record = state.record(window_id)?; + record .restore_enabled - .then(|| state.window.clone()) + .then(|| record.window.clone()) .flatten() }; if let Some(mut saved_window) = saved_window { - let startup_scale = window.scale_factor().unwrap_or(1.0); let displays = window .available_monitors() .unwrap_or_default() .into_iter() .map(|monitor| { let work_area = monitor.work_area(); - let position = work_area.position.to_logical::(startup_scale); - let size = work_area.size.to_logical::(startup_scale); DisplayArea { - x: position.x, - y: position.y, - width: size.width, - height: size.height, + x: work_area.position.x, + y: work_area.position.y, + width: work_area.size.width, + height: work_area.size.height, + scale_factor: monitor.scale_factor(), } }) .collect::>(); - if let Some(bounds) = clamp_window_bounds(&saved_window.bounds, &displays) { - let _ = window.set_size(LogicalSize::new(bounds.width as u32, bounds.height as u32)); - let _ = window.set_position(LogicalPosition::new(bounds.x, bounds.y)); - saved_window.bounds = bounds; + if let Some(bounds) = clamp_window_bounds_for_restore(&saved_window.bounds, &displays) { + let _ = window.set_size(PhysicalSize::new( + bounds.physical.width as u32, + bounds.physical.height as u32, + )); + let _ = + window.set_position(PhysicalPosition::new(bounds.physical.x, bounds.physical.y)); + saved_window.bounds = bounds.logical; } else if let Ok(position) = window.outer_position() { if let Ok(size) = window.inner_size() { let scale_factor = window.scale_factor().unwrap_or(1.0); @@ -314,7 +392,9 @@ pub fn setup_main_window(app: &AppHandle) -> Result<(), String> { } } if let Ok(mut state) = client_state.state.lock() { - state.window = Some(saved_window.clone()); + if let Ok(record) = state.record_mut(window_id) { + record.window = Some(saved_window.clone()); + } } let _ = window.set_zoom(saved_window.zoom_factor); if saved_window.maximized { @@ -328,13 +408,15 @@ pub fn setup_main_window(app: &AppHandle) -> Result<(), String> { } } - capture_main_window_in_memory(app); + capture_window_in_memory(app, window.label(), window_id, persisted); let app_handle = app.clone(); + let window_label = window.label().to_string(); + let window_id = window_id.to_string(); window.on_window_event(move |event| match event { WindowEvent::Resized(_) | WindowEvent::Moved(_) | WindowEvent::ScaleFactorChanged { .. } => { - capture_main_window_in_memory(&app_handle); + capture_window_in_memory(&app_handle, &window_label, &window_id, persisted); schedule_flush(&app_handle); } _ => {} @@ -343,8 +425,8 @@ pub fn setup_main_window(app: &AppHandle) -> Result<(), String> { Ok(()) } -pub fn set_main_window_zoom(app: &AppHandle, next_zoom: f64) { - let Some(window) = app.get_webview_window("main") else { +pub fn set_local_window_zoom(app: &AppHandle, window_label: &str, next_zoom: f64) { + let Some(window) = app.get_webview_window(window_label) else { return; }; let normalized = normalize_zoom_level(Some(next_zoom)); @@ -354,26 +436,59 @@ pub fn set_main_window_zoom(app: &AppHandle, next_zoom: f64) { let Some(client_state) = app.try_state::() else { return; }; - if let Ok(mut zoom_level) = client_state.zoom_level.lock() { - *zoom_level = normalized; + let Ok(window_id) = crate::identity::local_window_id(window_label) else { + return; + }; + if let Ok(mut zoom_levels) = client_state.zoom_levels.lock() { + zoom_levels.insert(window_id.clone(), normalized); } - capture_main_window_in_memory(app); + let persisted = app + .try_state::() + .and_then(|windows| windows.record(window_label)) + .is_some_and(|record| record.persisted); + capture_window_in_memory(app, window_label, &window_id, persisted); if let Err(err) = client_state.flush() { eprintln!("[client-state] failed to save zoom level: {err}"); } } -pub fn main_window_zoom(app: &AppHandle) -> f64 { +pub fn local_window_zoom(app: &AppHandle, window_label: &str) -> f64 { app.try_state::() - .and_then(|state| state.zoom_level.lock().ok().map(|zoom| *zoom)) + .and_then(|state| { + let window_id = crate::identity::local_window_id(window_label).ok()?; + state + .zoom_levels + .lock() + .ok() + .and_then(|zoom| zoom.get(&window_id).copied()) + }) .unwrap_or(DEFAULT_ZOOM_LEVEL) } -pub fn capture_and_flush_main_window(app: &AppHandle) { - capture_main_window_in_memory(app); +pub fn capture_and_flush_window(app: &AppHandle, window_label: &str) { + let Some(record) = app + .try_state::() + .and_then(|windows| windows.record(window_label)) + else { + return; + }; + capture_window_in_memory(app, window_label, &record.id, record.persisted); if let Some(state) = app.try_state::() { if let Err(err) = state.flush() { - eprintln!("[client-state] failed to flush main window state: {err}"); + eprintln!("[client-state] failed to flush local window state: {err}"); + } + } +} + +pub fn capture_and_flush_all_windows(app: &AppHandle) { + if let Some(windows) = app.try_state::() { + for record in windows.records() { + capture_window_in_memory(app, &record.label, &record.id, record.persisted); + } + } + if let Some(state) = app.try_state::() { + if let Err(err) = state.flush() { + eprintln!("[client-state] failed to flush local window state: {err}"); } } } diff --git a/packages/tauri-app/src-tauri/src/identity.rs b/packages/tauri-app/src-tauri/src/identity.rs new file mode 100644 index 00000000..5c284092 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/identity.rs @@ -0,0 +1,241 @@ +use sha2::{Digest, Sha256}; +use std::path::{Component, Path, PathBuf}; + +pub(crate) const STABLE_IDENTIFIER: &str = "ai.neuralnomads.codenomad.client"; +pub(crate) const LOCAL_WINDOW_PREFIX: &str = "local-"; +const DEFAULT_CONFIG: &str = "~/.config/codenomad/config.json"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct IdentityScope { + pub(crate) channel: String, + pub(crate) config_identity: String, + pub(crate) suffix: String, + pub(crate) scoped: bool, + pub(crate) identifier: String, + pub(crate) client_state_directory: Option, + pub(crate) webview_data_directory: PathBuf, +} + +pub(crate) fn local_window_label(window_id: &str) -> Result { + let uuid = + uuid::Uuid::parse_str(window_id).map_err(|_| "Invalid local window UUID".to_string())?; + let normalized = uuid.to_string(); + if normalized != window_id { + return Err("Local window UUID must be lowercase".to_string()); + } + Ok(format!("{LOCAL_WINDOW_PREFIX}{normalized}")) +} + +pub(crate) fn local_window_id(label: &str) -> Result { + let id = label + .strip_prefix(LOCAL_WINDOW_PREFIX) + .ok_or_else(|| "Native operation is limited to local windows".to_string())?; + local_window_label(id)?; + Ok(id.to_string()) +} + +pub(crate) fn resolve_update_channel( + explicit: Option<&str>, + version: &str, + packaged: bool, +) -> String { + if let Some(value) = explicit.map(str::trim).filter(|value| !value.is_empty()) { + let mut normalized = String::new(); + for character in value.to_ascii_lowercase().chars() { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + normalized.push(character); + } else if !normalized.ends_with('-') { + normalized.push('-'); + } + } + return normalized; + } + if !packaged { + return "dev".to_string(); + } + let lower = version.to_ascii_lowercase(); + if lower.contains("-dev.") || lower.contains("-dev-") { + "dev".to_string() + } else { + "stable".to_string() + } +} + +fn lexical_normalize(path: PathBuf) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if normalized.file_name().is_some() { + normalized.pop(); + } else if !normalized.has_root() { + normalized.push(".."); + } + } + value => normalized.push(value.as_os_str()), + } + } + normalized +} + +pub(crate) fn normalize_config_identity(raw: Option<&str>, cwd: &Path, home: &Path) -> String { + let value = raw + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_CONFIG); + let mut path = if value == "~" { + home.to_path_buf() + } else if let Some(rest) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + { + home.join(rest) + } else { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else { + cwd.join(path) + } + }; + path = lexical_normalize(path); + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + path.set_file_name("config.yaml"); + } else if !path.extension().is_some_and(|extension| { + extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml") + }) { + path.push("config.yaml"); + } + let identity = path.to_string_lossy().into_owned(); + if cfg!(windows) { + identity.replace('/', "\\").to_ascii_lowercase() + } else { + identity + } +} + +fn electron_user_data_base(home: &Path) -> PathBuf { + if cfg!(windows) { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData/Roaming")) + .join("CodeNomad") + } else if cfg!(target_os = "macos") { + home.join("Library/Application Support/CodeNomad") + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".config")) + .join("CodeNomad") + } +} + +pub(crate) fn resolve_scope( + explicit_channel: Option<&str>, + cli_config: Option<&str>, + version: &str, + packaged: bool, + cwd: &Path, + home: &Path, + local_data: &Path, +) -> IdentityScope { + let channel = resolve_update_channel(explicit_channel, version, packaged); + let config_identity = normalize_config_identity(cli_config, cwd, home); + let default_identity = normalize_config_identity(None, cwd, home); + let scoped = channel != "stable" || config_identity != default_identity; + let digest = Sha256::digest(format!("{channel}\0{config_identity}").as_bytes()); + let suffix = digest[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let scope_name = format!("{channel}-{suffix}"); + let identifier = if scoped { + format!("{STABLE_IDENTIFIER}.scope.s{suffix}") + } else { + STABLE_IDENTIFIER.to_string() + }; + let webview_root = local_data.join(format!("{STABLE_IDENTIFIER}-v2")); + IdentityScope { + channel, + config_identity, + suffix, + scoped, + identifier, + client_state_directory: scoped.then(|| { + electron_user_data_base(home) + .join("scopes") + .join(&scope_name) + .join("client-state") + }), + webview_data_directory: if scoped { + webview_root.join("scopes").join(scope_name) + } else { + webview_root + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_and_default_compatibility_match_electron() { + assert_eq!( + resolve_update_channel(Some("Beta Channel"), "1.0.0", false), + "beta-channel" + ); + assert_eq!(resolve_update_channel(None, "1.0.0", false), "dev"); + assert_eq!(resolve_update_channel(None, "1.0.0-dev.2", true), "dev"); + assert_eq!(resolve_update_channel(None, "1.0.0", true), "stable"); + let root = Path::new("/home/dev"); + let stable = resolve_scope(None, None, "1.0.0", true, root, root, Path::new("/local")); + assert!(!stable.scoped); + assert_eq!(stable.identifier, STABLE_IDENTIFIER); + assert_eq!(stable.client_state_directory, None); + let alternate = resolve_scope( + None, + Some("other/config.json"), + "1.0.0", + true, + root, + root, + Path::new("/local"), + ); + assert!(alternate.scoped); + assert!(alternate + .identifier + .starts_with(&format!("{STABLE_IDENTIFIER}.scope.s"))); + assert!(alternate + .client_state_directory + .unwrap() + .ends_with(Path::new("client-state"))); + } + + #[test] + fn config_json_and_yaml_have_one_semantic_identity() { + let cwd = Path::new("/work"); + let home = Path::new("/home/dev"); + assert_eq!( + normalize_config_identity(Some("config.json"), cwd, home), + normalize_config_identity(Some("config.yaml"), cwd, home) + ); + assert_eq!( + normalize_config_identity(Some("../work/./config.json"), cwd, home), + normalize_config_identity(Some("config.yaml"), cwd, home) + ); + } + + #[test] + fn local_labels_are_lowercase_uuid_backed() { + let id = "11111111-2222-4333-8444-555555555555"; + assert_eq!(local_window_label(id).unwrap(), format!("local-{id}")); + assert_eq!(local_window_id(&format!("local-{id}")).unwrap(), id); + assert!(local_window_id("remote-11111111-2222-4333-8444-555555555555").is_err()); + assert!(local_window_id("local-11111111-2222-4333-8444-AAAAAAAAAAAA").is_err()); + } +} diff --git a/packages/tauri-app/src-tauri/src/launch.rs b/packages/tauri-app/src-tauri/src/launch.rs new file mode 100644 index 00000000..339154d0 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/launch.rs @@ -0,0 +1,177 @@ +use std::collections::{HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct LaunchIntent { + pub(crate) new_window: bool, + pub(crate) folders: Vec, +} + +fn directory(value: &str, cwd: &Path) -> Option { + let path = if Path::new(value).is_absolute() { + PathBuf::from(value) + } else { + cwd.join(value) + }; + path.is_dir().then(|| path.to_string_lossy().into_owned()) +} + +pub(crate) fn parse_launch_intent(arguments: &[String], cwd: &Path) -> LaunchIntent { + let mut intent = LaunchIntent::default(); + let mut seen = HashSet::new(); + let mut index = 0; + while index < arguments.len() { + let value = &arguments[index]; + if value == "--new-window" { + intent.new_window = true; + } else if value == "--folder" { + if let Some(folder) = arguments + .get(index + 1) + .filter(|folder| !folder.starts_with('-')) + { + index += 1; + if let Some(folder) = directory(folder, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } + } else if let Some(value) = value.strip_prefix("--folder=") { + if let Some(folder) = directory(value, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } else if !value.starts_with('-') { + if let Some(folder) = directory(value, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } + index += 1; + } + intent +} + +pub(crate) fn parse_windows_forwarded_launch_intent( + arguments: &[String], + cwd: &Path, +) -> LaunchIntent { + let mut intent = LaunchIntent::default(); + let mut seen = HashSet::new(); + let mut unknown_fragment = false; + let mut index = 0; + while index < arguments.len() { + let value = &arguments[index]; + if value == "--new-window" { + intent.new_window = true; + } else if value == "--folder" { + if let Some(folder) = arguments + .get(index + 1) + .filter(|folder| !folder.starts_with('-')) + { + index += 1; + if let Some(folder) = directory(folder, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } + } else if let Some(value) = value.strip_prefix("--folder=") { + if let Some(folder) = directory(value, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } else if !value.starts_with('-') { + unknown_fragment = true; + } + index += 1; + } + if unknown_fragment { + intent.folders.clear(); + } + intent +} + +#[derive(Default)] +pub(crate) struct LaunchQueue { + pending: Mutex>, +} + +impl LaunchQueue { + pub(crate) fn enqueue(&self, intent: LaunchIntent) { + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push_back(intent); + } + + pub(crate) fn drain(&self) -> Vec { + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .drain(..) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_arguments_use_callback_cwd_and_ignore_flags() { + let root = tempfile::tempdir().unwrap(); + let folder = root.path().join("workspace"); + std::fs::create_dir(&folder).unwrap(); + let args = [ + "--ignored", + "--new-window", + "--folder", + "workspace", + "missing", + "workspace", + ] + .map(str::to_string); + assert_eq!( + parse_launch_intent(&args, root.path()), + LaunchIntent { + new_window: true, + folders: vec![folder.to_string_lossy().into_owned()] + } + ); + } + + #[test] + fn windows_forwarding_requires_explicit_valid_folders_and_rejects_fragments() { + let root = tempfile::tempdir().unwrap(); + let folder = root.path().join("workspace"); + std::fs::create_dir(&folder).unwrap(); + assert_eq!( + parse_windows_forwarded_launch_intent( + &["--folder".into(), "workspace".into()], + root.path(), + ) + .folders, + vec![folder.to_string_lossy().into_owned()] + ); + assert!( + parse_windows_forwarded_launch_intent(&["workspace".into()], root.path()) + .folders + .is_empty() + ); + assert!(parse_windows_forwarded_launch_intent( + &[ + "--folder".into(), + "workspace".into(), + "pipe-fragment".into() + ], + root.path(), + ) + .folders + .is_empty()); + } +} diff --git a/packages/tauri-app/src-tauri/src/local_windows.rs b/packages/tauri-app/src-tauri/src/local_windows.rs new file mode 100644 index 00000000..9d0e3c57 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/local_windows.rs @@ -0,0 +1,503 @@ +use crate::client_state::{self, ClientState, NavigationKind}; +use crate::identity::{local_window_id, local_window_label}; +use crate::launch::LaunchIntent; +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +use url::Url; + +const MAX_LOCAL_WINDOWS: usize = 16; +const MAX_FOLDER_ATTEMPTS: u8 = 3; +const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = + "window.__CODENOMAD_RUNTIME_HOST__ = 'tauri'; window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';"; + +#[derive(Clone, Debug)] +pub(crate) struct LocalWindowRecord { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) persisted: bool, + pending_folders: VecDeque, + renderer_ready: bool, + workspace_menu_enabled: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PendingFolder { + path: String, + attempts: u8, +} + +#[derive(Default)] +struct Registry { + records: HashMap, + mru: Vec, + backend_target: Option, +} + +impl Registry { + fn add(&mut self, id: String, persisted: bool) -> Result { + let label = local_window_label(&id)?; + if self.records.contains_key(&label) { + return Err("Local window is already registered".to_string()); + } + if self.records.len() >= MAX_LOCAL_WINDOWS { + return Err("Too many local windows".to_string()); + } + let record = LocalWindowRecord { + id, + label: label.clone(), + persisted, + pending_folders: VecDeque::new(), + renderer_ready: false, + workspace_menu_enabled: false, + }; + self.records.insert(label.clone(), record.clone()); + self.mark_focused(&label); + Ok(record) + } + + fn remove(&mut self, label: &str) -> Option { + self.mru.retain(|candidate| candidate != label); + self.records.remove(label) + } + + fn mark_focused(&mut self, label: &str) -> Option { + let id = self.records.get(label)?.id.clone(); + self.mru.retain(|candidate| candidate != label); + self.mru.insert(0, label.to_string()); + Some(id) + } + + fn mru_label(&self) -> Option { + self.mru + .iter() + .find(|label| self.records.contains_key(*label)) + .cloned() + } +} + +#[derive(Default)] +pub(crate) struct LocalWindows { + registry: Mutex, +} + +impl LocalWindows { + pub(crate) fn records(&self) -> Vec { + self.registry + .lock() + .unwrap_or_else(|error| error.into_inner()) + .records + .values() + .cloned() + .collect() + } + + pub(crate) fn record(&self, label: &str) -> Option { + self.registry.lock().ok()?.records.get(label).cloned() + } + + pub(crate) fn contains_id(&self, id: &str) -> bool { + local_window_label(id).ok().is_some_and(|label| { + self.registry + .lock() + .ok() + .is_some_and(|registry| registry.records.contains_key(&label)) + }) + } + + pub(crate) fn mru_label(&self) -> Option { + self.registry.lock().ok()?.mru_label() + } + + pub(crate) fn mark_focused(&self, app: &AppHandle, label: &str) { + let id = self + .registry + .lock() + .ok() + .and_then(|mut registry| registry.mark_focused(label)); + if let (Some(id), Some(state)) = (id, app.try_state::()) { + if let Err(error) = state.set_active_window(&id) { + eprintln!("[client-state] failed to persist active window: {error}"); + } + } + } + + pub(crate) fn remove_runtime(&self, label: &str) -> Option { + self.registry.lock().ok()?.remove(label) + } + + pub(crate) fn set_workspace_menu_enabled( + &self, + label: &str, + enabled: bool, + ) -> Result<(), String> { + let mut registry = self.registry.lock().map_err(|error| error.to_string())?; + let record = registry + .records + .get_mut(label) + .ok_or_else(|| "Unknown local window".to_string())?; + record.workspace_menu_enabled = enabled; + Ok(()) + } + + pub(crate) fn workspace_menu_enabled(&self, label: &str) -> bool { + self.registry + .lock() + .ok() + .and_then(|registry| { + registry + .records + .get(label) + .map(|record| record.workspace_menu_enabled) + }) + .unwrap_or(false) + } + + pub(crate) fn set_backend_target(&self, target: Option) { + if let Ok(mut registry) = self.registry.lock() { + registry.backend_target = target; + } + } + + fn backend_target(&self) -> Option { + self.registry.lock().ok()?.backend_target.clone() + } + + pub(crate) fn queue_folder( + &self, + app: &AppHandle, + label: &str, + folder: String, + ) -> Result<(), String> { + let ready = { + let mut registry = self.registry.lock().map_err(|error| error.to_string())?; + let record = registry + .records + .get_mut(label) + .ok_or_else(|| "Unknown local window".to_string())?; + record.pending_folders.push_back(PendingFolder { + path: folder, + attempts: 0, + }); + record.renderer_ready + }; + if ready { + if let Some(window) = app.get_webview_window(label) { + let _ = window.emit("desktop:folders-pending", ()); + } + } + Ok(()) + } + + pub(crate) fn renderer_ready(&self, app: &AppHandle, label: &str) -> Result<(), String> { + let pending = { + let mut registry = self.registry.lock().map_err(|error| error.to_string())?; + let record = registry + .records + .get_mut(label) + .ok_or_else(|| "Unknown local window".to_string())?; + record.renderer_ready = true; + !record.pending_folders.is_empty() + }; + if pending { + if let Some(window) = app.get_webview_window(label) { + let _ = window.emit("desktop:folders-pending", ()); + } + } + Ok(()) + } + + pub(crate) fn next_folder(&self, label: &str) -> Result, String> { + let registry = self.registry.lock().map_err(|error| error.to_string())?; + Ok(registry + .records + .get(label) + .ok_or_else(|| "Unknown local window".to_string())? + .pending_folders + .front() + .map(|pending| pending.path.clone())) + } + + pub(crate) fn acknowledge_folder( + &self, + label: &str, + folder: &str, + opened: bool, + ) -> Result<(), String> { + let mut registry = self.registry.lock().map_err(|error| error.to_string())?; + let record = registry + .records + .get_mut(label) + .ok_or_else(|| "Unknown local window".to_string())?; + if record + .pending_folders + .front() + .map(|pending| pending.path.as_str()) + != Some(folder) + { + return Err("Pending folder acknowledgement is out of order".to_string()); + } + let mut pending = record.pending_folders.pop_front().unwrap(); + if !opened { + pending.attempts += 1; + if pending.attempts < MAX_FOLDER_ATTEMPTS { + record.pending_folders.push_back(pending); + } + } + Ok(()) + } +} + +pub(crate) fn focused_window(app: &AppHandle) -> Option { + app.webview_windows() + .into_values() + .find(|window| window.is_focused().unwrap_or(false)) +} + +fn select_local_label(focused: Option<&str>, mru: Option<&str>) -> Option { + match focused { + Some(label) if local_window_id(label).is_ok() => Some(label.to_string()), + Some(_) => None, + None => mru + .filter(|label| local_window_id(label).is_ok()) + .map(str::to_string), + } +} + +pub(crate) fn focused_local_window(app: &AppHandle) -> Option { + let focused = focused_window(app); + let mru = app.state::().mru_label(); + let label = select_local_label(focused.as_ref().map(WebviewWindow::label), mru.as_deref())?; + app.get_webview_window(&label) +} + +pub(crate) fn targeted_window(app: &AppHandle) -> Option { + focused_window(app).or_else(|| { + app.state::() + .mru_label() + .and_then(|label| app.get_webview_window(&label)) + }) +} + +pub(crate) fn focus(app: &AppHandle, label: &str) -> bool { + let Some(window) = app.get_webview_window(label) else { + return false; + }; + if window.is_minimized().unwrap_or(false) { + let _ = window.unminimize(); + } + let _ = window.show(); + let _ = window.set_focus(); + app.state::().mark_focused(app, label); + true +} + +fn register(app: &AppHandle, id: String, persisted: bool) -> Result { + app.state::() + .registry + .lock() + .map_err(|error| error.to_string())? + .add(id, persisted) +} + +pub(crate) fn create_local_window( + app: &AppHandle, + id: String, + persisted: bool, +) -> Result { + let record = register(app, id.clone(), persisted)?; + let script = format!( + "{LOCAL_WINDOW_CONTEXT_SCRIPT} window.__CODENOMAD_WINDOW_ID__ = {};", + serde_json::to_string(&id).unwrap() + ); + let data_directory = app + .state::() + .webview_data_directory + .join("local"); + let builder = + WebviewWindowBuilder::new(app, &record.label, WebviewUrl::App("loading.html".into())) + .data_directory(data_directory) + .initialization_script(script); + #[cfg(target_os = "macos")] + let builder = if app.state::().scoped_profile { + builder.data_store_identifier(crate::profile_identifier("local")) + } else { + builder + }; + let result = builder + .title("CodeNomad") + .inner_size(1400.0, 900.0) + .min_inner_size(800.0, 600.0) + .resizable(true) + .fullscreen(false) + .decorations(true) + .background_color(tauri::window::Color(26, 26, 26, 255)) + .zoom_hotkeys_enabled(true) + .visible(false) + .build(); + let window = match result { + Ok(window) => window, + Err(error) => { + app.state::().remove_runtime(&record.label); + return Err(error.to_string()); + } + }; + if let Err(error) = client_state::setup_local_window(app, &window, &id, persisted) { + app.state::().remove_runtime(&record.label); + if let Some(state) = app.try_state::() { + state.unregister_window(&id); + } + let _ = window.destroy(); + return Err(error); + } + #[cfg(windows)] + if let Err(error) = crate::shutdown::schedule_windows_session_end_handler(&window) { + app.state::().remove_runtime(&record.label); + if let Some(state) = app.try_state::() { + state.unregister_window(&id); + } + let _ = window.destroy(); + return Err(error); + } + if let Some(target) = app.state::().backend_target() { + navigate_window(app, &record.label, &target, NavigationKind::Cli); + } + Ok(record) +} + +pub(crate) fn create_new_window(app: &AppHandle) -> Result { + let state = app.state::(); + if let Some(id) = state + .window_ids() + .into_iter() + .find(|id| !app.state::().contains_id(id)) + { + return create_local_window(app, id, state.is_primary_process()); + } + if app.state::().records().len() >= MAX_LOCAL_WINDOWS { + return Err("Too many local windows".to_string()); + } + let id = uuid::Uuid::new_v4().to_string(); + let persisted = match state.add_window(id.clone()) { + Ok(persisted) => persisted, + Err(error) => { + eprintln!( + "[client-state] failed to persist a new window; using an ephemeral window: {error}" + ); + false + } + }; + create_local_window(app, id, persisted) +} + +pub(crate) fn restore_windows(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let persisted = state.is_primary_process(); + let ids = state.window_ids(); + let active = state.active_window_id()?; + drop(state); + if ids.is_empty() { + create_new_window(app)?; + return Ok(()); + } + for id in ids { + create_local_window(app, id, persisted)?; + } + let label = local_window_label(&active)?; + focus(app, &label); + Ok(()) +} + +pub(crate) fn handle_intent(app: &AppHandle, intent: LaunchIntent) -> Result<(), String> { + let record = if intent.new_window { + create_new_window(app)? + } else if let Some(label) = app.state::().mru_label() { + app.state::() + .record(&label) + .ok_or_else(|| "MRU local window disappeared".to_string())? + } else { + create_new_window(app)? + }; + for folder in intent.folders { + app.state::() + .queue_folder(app, &record.label, folder)?; + } + focus(app, &record.label); + Ok(()) +} + +pub(crate) fn navigate_window(app: &AppHandle, label: &str, target: &str, kind: NavigationKind) { + let Ok(url) = Url::parse(target) else { + return; + }; + let label_for_navigation = label.to_string(); + client_state::before_window_navigation( + app, + label.to_string(), + kind, + Some(url.clone()), + move |app| { + app.get_webview_window(&label_for_navigation) + .ok_or_else(|| "local window not found for navigation".to_string())? + .navigate(url) + .map_err(|error| error.to_string()) + }, + ); +} + +pub(crate) fn show_loading_all(app: &AppHandle) { + app.state::().set_backend_target(None); + for record in app.state::().records() { + navigate_window( + app, + &record.label, + "tauri://localhost/loading.html", + NavigationKind::Cli, + ); + } +} + +pub(crate) fn emit_all(app: &AppHandle, event: &str, payload: impl serde::Serialize + Clone) { + for record in app.state::().records() { + if let Some(window) = app.get_webview_window(&record.label) { + let _ = window.emit(event, payload.clone()); + } + } +} + +#[tauri::command] +pub(crate) fn desktop_launch_ready( + window: WebviewWindow, + app: AppHandle, + windows: tauri::State<'_, LocalWindows>, + state: tauri::State<'_, crate::AppState>, +) -> Result<(), String> { + crate::require_local_app_window(&window, &state)?; + windows.renderer_ready(&app, window.label()) +} + +#[tauri::command] +pub(crate) fn desktop_launch_next_folder( + window: WebviewWindow, + windows: tauri::State<'_, LocalWindows>, + state: tauri::State<'_, crate::AppState>, +) -> Result, String> { + crate::require_local_app_window(&window, &state)?; + windows.next_folder(window.label()) +} + +#[tauri::command] +pub(crate) fn desktop_launch_acknowledge_folder( + window: WebviewWindow, + windows: tauri::State<'_, LocalWindows>, + state: tauri::State<'_, crate::AppState>, + folder: String, + opened: bool, +) -> Result<(), String> { + crate::require_local_app_window(&window, &state)?; + windows.acknowledge_folder(window.label(), &folder, opened) +} + +#[cfg(test)] +#[path = "local_windows_tests.rs"] +mod tests; diff --git a/packages/tauri-app/src-tauri/src/local_windows_tests.rs b/packages/tauri-app/src-tauri/src/local_windows_tests.rs new file mode 100644 index 00000000..7c1636ff --- /dev/null +++ b/packages/tauri-app/src-tauri/src/local_windows_tests.rs @@ -0,0 +1,56 @@ +use super::*; + +fn id(value: u8) -> String { + format!("00000000-0000-4000-8000-{value:012}") +} + +#[test] +fn registry_tracks_uuid_labels_mru_and_folder_ack_order() { + let mut registry = Registry::default(); + let first = registry.add(id(1), true).unwrap(); + let second = registry.add(id(2), true).unwrap(); + assert_eq!(registry.mru_label(), Some(second.label.clone())); + assert_eq!(registry.mark_focused(&first.label), Some(first.id.clone())); + assert_eq!(registry.mru_label(), Some(first.label.clone())); + let record = registry.records.get_mut(&first.label).unwrap(); + record.pending_folders.push_back(PendingFolder { + path: "one".to_string(), + attempts: 0, + }); + record.pending_folders.push_back(PendingFolder { + path: "two".to_string(), + attempts: 0, + }); + assert_eq!( + record + .pending_folders + .front() + .map(|pending| pending.path.as_str()), + Some("one") + ); + assert_eq!(registry.remove(&first.label).unwrap().id, first.id); + assert_eq!(registry.mru_label(), Some(second.label)); +} + +#[test] +fn registry_enforces_maximum_and_lowercase_labels() { + let mut registry = Registry::default(); + for value in 0..MAX_LOCAL_WINDOWS { + registry.add(id(value as u8), true).unwrap(); + } + assert_eq!( + registry.add(id(99), false).unwrap_err(), + "Too many local windows" + ); +} + +#[test] +fn remote_focus_never_falls_through_to_background_local() { + let local = format!("local-{}", id(1)); + assert_eq!( + select_local_label(Some("remote-profile"), Some(&local)), + None + ); + assert_eq!(select_local_label(None, Some(&local)), Some(local.clone())); + assert_eq!(select_local_label(Some(&local), None), Some(local)); +} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index b22950d1..46a58bba 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -4,8 +4,11 @@ mod cert_manager; mod cli_manager; mod client_state; +mod identity; +mod launch; #[cfg(target_os = "linux")] mod linux_tls; +mod local_windows; mod managed_node; mod shutdown; mod windows_update; @@ -15,9 +18,11 @@ use cli_manager::{CliProcessManager, CliStatus}; use keepawake::KeepAwake; use serde::Deserialize; use serde_json::json; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; #[cfg(any(windows, test))] use std::future::Future; +use std::sync::Arc; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::menu::{ @@ -45,23 +50,55 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; const ZOOM_STEP: f64 = 0.1; const RELEASES_URL: &str = "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"; -const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';"; const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_RUNTIME_HOST__ = 'tauri'; window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';"; -#[cfg(windows)] -const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; - pub struct AppState { pub manager: CliProcessManager, - pub wake_lock: Mutex>, + pub wake_lock: Mutex, pub remote_origins: Mutex>, - pub remote_proxy_sessions: Mutex>, + remote_profiles: Mutex>, pub remote_skip_tls_verify: Mutex>, pub remote_tls_handlers: Mutex>, pub remote_titles: Mutex>, + pub remote_zoom_levels: Mutex>, pub workspace_menu_items: Mutex>, - pub workspace_menu_requested_enabled: Mutex, + pub webview_data_directory: std::path::PathBuf, + pub scoped_profile: bool, +} + +#[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, + handle: Option, } pub struct WorkspaceMenuItems { @@ -72,18 +109,14 @@ pub struct WorkspaceMenuItems { fn update_workspace_menu_state(app: &AppHandle) { let state = app.state::(); - let requested = state - .workspace_menu_requested_enabled - .lock() - .map(|value| *value) - .unwrap_or(false); - let focused = app - .get_webview_window("main") - .and_then(|window| window.is_focused().ok()) - .unwrap_or(false); + let enabled = local_windows::focused_window(app) + .filter(|window| identity::local_window_id(window.label()).is_ok()) + .is_some_and(|window| { + app.state::() + .workspace_menu_enabled(window.label()) + }); if let Ok(items) = state.workspace_menu_items.lock() { if let Some(items) = items.as_ref() { - let enabled = requested && focused; let _ = items.folder.set_enabled(enabled); let _ = items.terminal.set_enabled(enabled); let _ = items.editor.set_enabled(enabled); @@ -91,6 +124,55 @@ fn update_workspace_menu_state(app: &AppHandle) { }; } +fn is_asset_renderer_origin(url: &Url) -> bool { + match url.scheme() { + "tauri" | "asset" => url.host_str() == Some("localhost") && url.port().is_none(), + "http" | "https" => url.host_str() == Some("tauri.localhost") && url.port().is_none(), + _ => false, + } +} + +fn is_dev_renderer_origin(url: &Url) -> bool { + cfg!(debug_assertions) + && url.scheme() == "http" + && matches!(url.host_str(), Some("127.0.0.1" | "localhost")) + && url.port() == Some(1420) +} + +fn same_origin(url: &Url, expected: Option<&str>) -> bool { + expected + .and_then(|value| Url::parse(value).ok()) + .is_some_and(|expected| url.origin() == expected.origin()) +} + +fn is_allowed_local_origin(url: &Url, managed_backend: Option<&str>) -> bool { + is_asset_renderer_origin(url) + || is_dev_renderer_origin(url) + || same_origin(url, managed_backend) +} + +pub(crate) fn require_local_app_window( + window: &tauri::WebviewWindow, + state: &AppState, +) -> Result { + identity::local_window_id(window.label())?; + let current = window.url().map_err(|error| error.to_string())?; + let status = state.manager.status(); + if is_allowed_local_origin(¤t, status.url.as_deref()) { + Ok(current) + } else { + Err("Native application commands require a trusted local renderer origin".into()) + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn profile_identifier(identity: &str) -> [u8; 16] { + let digest = Sha256::digest(identity.as_bytes()); + let mut identifier = [0_u8; 16]; + identifier.copy_from_slice(&digest[..16]); + identifier +} + #[tauri::command] fn set_workspace_menu_enabled( window: tauri::WebviewWindow, @@ -98,14 +180,10 @@ fn set_workspace_menu_enabled( state: tauri::State<'_, AppState>, enabled: bool, ) -> Result<(), String> { - if window.label() != "main" { - return Err("Workspace menu updates are limited to the local main window".into()); - } + require_local_app_window(&window, &state)?; if !enabled { - *state - .workspace_menu_requested_enabled - .lock() - .map_err(|error| error.to_string())? = false; + app.state::() + .set_workspace_menu_enabled(window.label(), false)?; update_workspace_menu_state(&app); return Ok(()); } @@ -118,10 +196,8 @@ fn set_workspace_menu_enabled( if current.origin() != expected.origin() { return Err("Workspace menu updates require the local CodeNomad origin".into()); } - *state - .workspace_menu_requested_enabled - .lock() - .map_err(|error| error.to_string())? = enabled; + app.state::() + .set_workspace_menu_enabled(window.label(), enabled)?; update_workspace_menu_state(&app); Ok(()) } @@ -194,12 +270,21 @@ struct WakeLockConfig { } #[tauri::command] -fn cli_get_status(state: tauri::State) -> CliStatus { - state.manager.status() +fn cli_get_status( + window: tauri::WebviewWindow, + state: tauri::State, +) -> Result { + require_local_app_window(&window, &state)?; + Ok(state.manager.status()) } #[tauri::command] -fn cli_restart(app: AppHandle, state: tauri::State) -> Result { +fn cli_restart( + window: tauri::WebviewWindow, + app: AppHandle, + state: tauri::State, +) -> Result { + require_local_app_window(&window, &state)?; let dev_mode = is_dev_mode(); state.manager.stop().map_err(|e| e.to_string())?; state @@ -211,9 +296,11 @@ fn cli_restart(app: AppHandle, state: tauri::State) -> Result, config: Option, ) -> Result<(), String> { + require_local_app_window(&window, &state)?; let config = config.unwrap_or(WakeLockConfig { display: false, idle: true, @@ -229,16 +316,25 @@ fn wake_lock_start( .app_name("CodeNomad") .app_reverse_domain("ai.neuralnomads.codenomad.client"); - let wake_lock = builder.create().map_err(|err| err.to_string())?; let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?; - *state_lock = Some(wake_lock); + state_lock.labels.insert(window.label().to_string()); + if state_lock.handle.is_none() { + state_lock.handle = Some(builder.create().map_err(|err| err.to_string())?); + } Ok(()) } #[tauri::command] -fn wake_lock_stop(state: tauri::State) -> Result<(), String> { +fn wake_lock_stop( + window: tauri::WebviewWindow, + state: tauri::State, +) -> Result<(), String> { + require_local_app_window(&window, &state)?; let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?; - state_lock.take(); + state_lock.labels.remove(window.label()); + if state_lock.labels.is_empty() { + state_lock.handle.take(); + } Ok(()) } @@ -247,17 +343,7 @@ fn is_dev_mode() -> bool { } fn should_allow_internal(url: &Url) -> bool { - match url.scheme() { - "tauri" | "asset" | "file" | "about" => true, - // On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`. - // This must be treated as an internal origin or the navigation guard will - // redirect it to the system browser and the app will appear blank. - "http" | "https" => matches!( - url.host_str(), - Some("127.0.0.1" | "localhost" | "tauri.localhost") - ), - _ => false, - } + is_asset_renderer_origin(url) || is_dev_renderer_origin(url) || url.as_str() == "about:blank" } fn should_allow_window_origin( @@ -266,6 +352,10 @@ fn should_allow_window_origin( url: &Url, ) -> bool { let state = app_handle.state::(); + if identity::local_window_id(window_label).is_ok() { + let status = state.manager.status(); + return is_allowed_local_origin(url, status.url.as_deref()); + } let Ok(allowed) = state.remote_origins.lock() else { return false; }; @@ -274,9 +364,9 @@ fn should_allow_window_origin( fn should_allow_registered_origin(registered_origin: Option<&str>, url: &Url) -> bool { if let Some(origin) = registered_origin { - if matches!(url.scheme(), "http" | "https") { - return origin == url.origin().ascii_serialization(); - } + return (matches!(url.scheme(), "http" | "https") + && origin == url.origin().ascii_serialization()) + || url.as_str() == "about:blank"; } should_allow_internal(url) } @@ -324,57 +414,86 @@ async fn open_remote_window_impl( let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?; let label = format!("remote-{}", payload.id); let title = format!("{} - {}", payload.name, payload.base_url); + let requested_profile = RemoteProfileIdentity::new(payload.proxy_session_id.as_deref()); let window_url = parsed.clone(); let allow_linux_tls_certificate = parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); - app.state::() - .remote_origins - .lock() - .map_err(|err| err.to_string())? - .insert(label.clone(), window_url.origin().ascii_serialization()); - app.state::() - .remote_skip_tls_verify - .lock() - .map_err(|err| err.to_string())? - .insert(label.clone(), allow_linux_tls_certificate); - app.state::() - .remote_titles - .lock() - .map_err(|err| err.to_string())? - .insert(label.clone(), title.clone()); - - let replaced_session = { - let state = app.state::(); - let mut sessions = state - .remote_proxy_sessions - .lock() - .map_err(|err| err.to_string())?; - match payload.proxy_session_id.clone() { - Some(session_id) => sessions.insert(label.clone(), session_id), - None => sessions.remove(&label), - } - }; - - if let Some(previous) = replaced_session { - if payload.proxy_session_id.as_deref() != Some(previous.as_str()) { - schedule_remote_proxy_session_cleanup(app.clone(), previous); - } - } + let mut previous_profile = None; if let Some(existing) = app.get_webview_window(&label) { - #[cfg(target_os = "linux")] - linux_tls::ensure_remote_window_tls_handler(&existing, &app, &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()); + } + } else { + set_remote_window_metadata( + &app, + &label, + &window_url, + &title, + allow_linux_tls_certificate, + )?; + #[cfg(target_os = "linux")] + linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?; + let _ = existing.set_title(&title); + let _ = existing.navigate(window_url.clone()); + 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 _ = existing.set_title(&title); - let _ = existing.navigate(window_url.clone()); - apply_remote_window_title(&app, &label); - let _ = existing.show(); - let _ = existing.unminimize(); - let _ = existing.set_focus(); - return Ok(()); + if let Err(error) = set_remote_window_metadata( + &app, + &label, + &window_url, + &title, + allow_linux_tls_certificate, + ) { + clear_remote_window_metadata(&app, &label, &requested_profile); + if let Some(session_id) = requested_profile.proxy_session_id() { + schedule_remote_proxy_session_cleanup(app.clone(), session_id.to_string()); + } + if let Some(session_id) = previous_profile + .as_ref() + .and_then(RemoteProfileIdentity::proxy_session_id) + { + schedule_remote_proxy_session_cleanup(app.clone(), session_id.to_string()); + } + return Err(error); } #[cfg(target_os = "linux")] @@ -388,17 +507,56 @@ async fn open_remote_window_impl( #[cfg(not(target_os = "linux"))] let initial_url = window_url.clone(); - let window = WebviewWindowBuilder::new( + 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) - .build() - .map_err(|err| err.to_string())?; + .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) => { + clear_remote_window_metadata(&app, &label, &requested_profile); + if let Some(session_id) = requested_profile.proxy_session_id() { + schedule_remote_proxy_session_cleanup(app.clone(), session_id.to_string()); + } + if let Some(session_id) = previous_profile + .as_ref() + .and_then(RemoteProfileIdentity::proxy_session_id) + { + schedule_remote_proxy_session_cleanup(app.clone(), session_id.to_string()); + } + return Err(error.to_string()); + } + }; + + 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(), session_id.to_string()); + } #[cfg(windows)] shutdown::schedule_windows_session_end_handler(&window)?; @@ -413,27 +571,18 @@ async fn open_remote_window_impl( 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 { - if let Ok(mut origins) = app_handle.state::().remote_origins.lock() { - origins.remove(&label_for_cleanup); + if !clear_remote_window_metadata(&app_handle, &label_for_cleanup, &profile_for_cleanup) + { + return; } - if let Ok(mut sessions) = app_handle.state::().remote_proxy_sessions.lock() { - if let Some(session_id) = sessions.remove(&label_for_cleanup) { - schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id); - } - } - if let Ok(mut values) = app_handle.state::().remote_skip_tls_verify.lock() { - values.remove(&label_for_cleanup); - } - if let Ok(mut handlers) = app_handle.state::().remote_tls_handlers.lock() { - handlers.remove(&label_for_cleanup); - } - if let Ok(mut titles) = app_handle.state::().remote_titles.lock() { - titles.remove(&label_for_cleanup); + if let Some(session_id) = profile_for_cleanup.proxy_session_id() { + schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id.to_string()); } } }); @@ -441,8 +590,67 @@ async fn open_remote_window_impl( Ok(()) } +fn set_remote_window_metadata( + app: &AppHandle, + label: &str, + url: &Url, + title: &str, + allow_linux_tls_certificate: bool, +) -> Result<(), String> { + let state = app.state::(); + state + .remote_origins + .lock() + .map_err(|err| err.to_string())? + .insert(label.to_string(), url.origin().ascii_serialization()); + state + .remote_skip_tls_verify + .lock() + .map_err(|err| err.to_string())? + .insert(label.to_string(), allow_linux_tls_certificate); + state + .remote_titles + .lock() + .map_err(|err| err.to_string())? + .insert(label.to_string(), title.to_string()); + Ok(()) +} + +fn clear_remote_window_metadata( + 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); + drop(profiles); + if let Ok(mut values) = state.remote_origins.lock() { + values.remove(label); + } + if let Ok(mut values) = state.remote_skip_tls_verify.lock() { + values.remove(label); + } + if let Ok(mut values) = state.remote_tls_handlers.lock() { + values.remove(label); + } + if let Ok(mut values) = state.remote_titles.lock() { + values.remove(label); + } + true +} + #[tauri::command] -fn needs_local_certificate_install() -> Result { +fn needs_local_certificate_install( + window: tauri::WebviewWindow, + state: tauri::State, +) -> Result { + require_local_app_window(&window, &state)?; #[cfg(not(target_os = "linux"))] { let local_cert = cert_manager::ensure_local_cert().map_err(|err| { @@ -460,7 +668,13 @@ fn needs_local_certificate_install() -> Result { } #[tauri::command] -async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> { +async fn open_remote_window( + window: tauri::WebviewWindow, + app: AppHandle, + state: tauri::State<'_, AppState>, + payload: RemoteWindowPayload, +) -> Result<(), String> { + require_local_app_window(&window, &state)?; #[cfg(not(target_os = "linux"))] { let entry_url = payload @@ -518,31 +732,49 @@ fn emit_folder_drop_event( } } -fn reload_main_window(app_handle: &AppHandle) { - client_state::before_main_window_navigation( +fn reload_target_window(app_handle: &AppHandle) { + let Some(window) = local_windows::targeted_window(app_handle) else { + return; + }; + let label = window.label().to_string(); + if identity::local_window_id(&label).is_err() { + let _ = window.reload(); + return; + } + let target_label = label.clone(); + client_state::before_window_navigation( app_handle, + label, client_state::NavigationKind::Reload, None, - |app| { - let window = app - .get_webview_window("main") - .ok_or_else(|| "main window not found for reload".to_string())?; - window + move |app| { + app.get_webview_window(&target_label) + .ok_or_else(|| "local window not found for reload".to_string())? .reload() - .map_err(|err| format!("failed to reload main window: {err}")) + .map_err(|err| format!("failed to reload local window: {err}")) }, ); } -fn force_reload_main_window(app_handle: &AppHandle) { - client_state::before_main_window_navigation( +fn force_reload_target_window(app_handle: &AppHandle) { + let Some(window) = local_windows::targeted_window(app_handle) else { + return; + }; + let label = window.label().to_string(); + if identity::local_window_id(&label).is_err() { + let _ = window.reload(); + return; + } + let target_label = label.clone(); + client_state::before_window_navigation( app_handle, + label, client_state::NavigationKind::ForceReload, None, - |app| { + move |app| { let window = app - .get_webview_window("main") - .ok_or_else(|| "main window not found for force reload".to_string())?; + .get_webview_window(&target_label) + .ok_or_else(|| "local window not found for force reload".to_string())?; if let Ok(mut url) = window.url() { if should_allow_internal(&url) { let reload_token = SystemTime::now() @@ -580,7 +812,7 @@ fn force_reload_main_window(app_handle: &AppHandle) { } fn toggle_fullscreen_window(app_handle: &AppHandle) { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let next_fullscreen = !window.is_fullscreen().unwrap_or(false); let _ = window.set_fullscreen(next_fullscreen); if cfg!(not(target_os = "macos")) { @@ -593,6 +825,32 @@ 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); + } + } +} + +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) + } +} + fn fullscreen_shortcut() -> Option { if cfg!(target_os = "macos") { None @@ -601,9 +859,23 @@ fn fullscreen_shortcut() -> Option { } } +fn update_fullscreen_shortcut(app: &AppHandle) { + let Some(shortcut) = fullscreen_shortcut() else { + return; + }; + let local_focused = app.webview_windows().into_values().any(|window| { + identity::local_window_id(window.label()).is_ok() && window.is_focused().unwrap_or(false) + }); + if local_focused { + let _ = app.global_shortcut().register(shortcut); + } else { + let _ = app.global_shortcut().unregister(shortcut); + } +} + #[cfg(windows)] -fn set_windows_app_user_model_id() { - let app_id: Vec = OsStr::new(WINDOWS_APP_USER_MODEL_ID) +fn set_windows_app_user_model_id(identifier: &str) { + let app_id: Vec = OsStr::new(identifier) .encode_wide() .chain(iter::once(0)) .collect(); @@ -615,19 +887,25 @@ fn set_windows_app_user_model_id() { } #[cfg(not(windows))] -fn set_windows_app_user_model_id() {} +fn set_windows_app_user_model_id(_identifier: &str) {} #[cfg(windows)] -fn isolate_windows_webview_profile() { +fn isolate_windows_webview_profile(scope: &identity::IdentityScope) { if std::env::var_os("WEBVIEW2_USER_DATA_FOLDER").is_some() { return; } - if let Some(root) = dirs::data_local_dir() { - std::env::set_var( - "WEBVIEW2_USER_DATA_FOLDER", - root.join("ai.neuralnomads.codenomad.client-v2"), - ); - } + std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &scope.webview_data_directory); +} + +fn schedule_launch_drain(app: AppHandle, queue: Arc) { + let dispatch = app.clone(); + let _ = app.run_on_main_thread(move || { + for intent in queue.drain() { + if let Err(error) = local_windows::handle_intent(&dispatch, intent) { + eprintln!("[tauri-startup] launch intent failed: {error}"); + } + } + }); } fn main() { @@ -637,14 +915,49 @@ fn main() { } let _ = rustls::crypto::ring::default_provider().install_default(); + let cwd = std::env::current_dir().unwrap_or_default(); + let home = dirs::home_dir().unwrap_or_else(|| cwd.clone()); + let local_data = dirs::data_local_dir().unwrap_or_else(|| home.clone()); + let scope = identity::resolve_scope( + std::env::var("CODENOMAD_UPDATE_CHANNEL").ok().as_deref(), + std::env::var("CLI_CONFIG").ok().as_deref(), + env!("CARGO_PKG_VERSION"), + !is_dev_mode(), + &cwd, + &home, + &local_data, + ); #[cfg(windows)] - isolate_windows_webview_profile(); + isolate_windows_webview_profile(&scope); + + let launch_queue = Arc::new(launch::LaunchQueue::default()); + launch_queue.enqueue(launch::parse_launch_intent( + &std::env::args().skip(1).collect::>(), + &cwd, + )); + let singleton_queue = Arc::clone(&launch_queue); + let single_instance = tauri_plugin_single_instance::init(move |app, args, callback_cwd| { + let cwd = std::path::PathBuf::from(callback_cwd); + let arguments = args.into_iter().skip(1).collect::>(); + #[cfg(windows)] + let intent = launch::parse_windows_forwarded_launch_intent(&arguments, &cwd); + #[cfg(not(windows))] + let intent = launch::parse_launch_intent(&arguments, &cwd); + singleton_queue.enqueue(intent); + schedule_launch_drain(app.clone(), Arc::clone(&singleton_queue)); + }); let navigation_guard: TauriPlugin = PluginBuilder::new("external-link-guard") .on_navigation(|webview, url| intercept_navigation(webview, url)) .build(); + let mut context = tauri::generate_context!(); + context.config_mut().identifier = scope.identifier.clone(); + let setup_scope = scope.clone(); + let setup_queue = Arc::clone(&launch_queue); + tauri::Builder::default() + .plugin(single_instance) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin( @@ -662,27 +975,42 @@ fn main() { ) .plugin(tauri_plugin_notification::init()) .plugin(navigation_guard) + .manage(local_windows::LocalWindows::default()) .manage(AppState { manager: CliProcessManager::new(), - wake_lock: Mutex::new(None), + wake_lock: Mutex::new(WakeLockState::default()), remote_origins: Mutex::new(HashMap::new()), - remote_proxy_sessions: Mutex::new(HashMap::new()), + remote_profiles: Mutex::new(HashMap::new()), remote_skip_tls_verify: Mutex::new(HashMap::new()), remote_tls_handlers: Mutex::new(HashSet::new()), remote_titles: Mutex::new(HashMap::new()), + remote_zoom_levels: Mutex::new(HashMap::new()), workspace_menu_items: Mutex::new(None), - workspace_menu_requested_enabled: Mutex::new(false), + webview_data_directory: setup_scope.webview_data_directory.clone(), + scoped_profile: setup_scope.scoped, }) .on_page_load(|webview, payload| { - if webview.label() == "main" && payload.event() == PageLoadEvent::Started { - if let Ok(mut enabled) = webview - .app_handle() - .state::() - .workspace_menu_requested_enabled - .lock() - { - *enabled = false; + if identity::local_window_id(webview.label()).is_ok() + && payload.event() == PageLoadEvent::Started + { + let app = webview.app_handle(); + let managed_backend = app.state::().manager.status().url; + if is_allowed_local_origin(payload.url(), managed_backend.as_deref()) { + if let (Ok(window_id), Some(state)) = ( + identity::local_window_id(webview.label()), + app.try_state::(), + ) { + if let Err(error) = + state.stage_renderer_page_load(&window_id, payload.url()) + { + eprintln!("[client-state] failed to stage renderer page load: {error}"); + } + } } + let _ = webview + .app_handle() + .state::() + .set_workspace_menu_enabled(webview.label(), false); update_workspace_menu_state(&webview.app_handle()); } if matches!( @@ -692,51 +1020,30 @@ fn main() { apply_remote_window_title(&webview.app_handle(), webview.label()); } }) - .setup(|app| { - set_windows_app_user_model_id(); - let client_state = client_state::ClientState::initialize(&app.handle()); + .setup(move |app| { + set_windows_app_user_model_id(&setup_scope.identifier); + let client_state = client_state::ClientState::initialize( + &app.handle(), + setup_scope.client_state_directory.as_deref(), + ); app.manage(client_state); app.manage(shutdown::ShutdownCoordinator::default()); build_menu(&app.handle())?; - client_state::setup_main_window(&app.handle()) + local_windows::restore_windows(&app.handle()) .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; - if let Some(window) = app.get_webview_window("main") { - #[cfg(windows)] - shutdown::schedule_windows_session_end_handler(&window) - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; - let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT); - let app_handle = app.handle().clone(); - window.on_window_event(move |event| { - if matches!(event, WindowEvent::Focused(_)) { - update_workspace_menu_state(&app_handle); - } - }); - } - if let Some(shortcut) = fullscreen_shortcut() { - let shortcut_manager = app.handle().global_shortcut(); - let _ = shortcut_manager.register(shortcut.clone()); - - if let Some(window) = app.get_webview_window("main") { - let app_handle = app.handle().clone(); - window.on_window_event(move |event| { - if let WindowEvent::Focused(focused) = event { - let shortcut_manager = app_handle.global_shortcut(); - if *focused { - let _ = shortcut_manager.register(shortcut.clone()); - } else { - let _ = shortcut_manager.unregister(shortcut.clone()); - } - } - }); - } - } + schedule_launch_drain(app.handle().clone(), Arc::clone(&setup_queue)); + update_fullscreen_shortcut(&app.handle()); let dev_mode = is_dev_mode(); let app_handle = app.handle().clone(); let manager = app.state::().manager.clone(); std::thread::spawn(move || { if let Err(err) = manager.start(app_handle.clone(), dev_mode) { - let _ = app_handle.emit("cli:error", json!({"message": err.to_string()})); + local_windows::emit_all( + &app_handle, + "cli:error", + json!({"message": err.to_string()}), + ); } }); Ok(()) @@ -751,10 +1058,15 @@ fn main() { client_state::client_state_claim_access, client_state::client_state_load, client_state::client_state_save, + client_state::client_state_commit_partitions, + client_state::client_state_load_partition, client_state::client_state_set_restore_enabled, client_state::client_state_clear, client_state::client_state_renderer_flushed, client_state::client_state_navigation_flushed, + local_windows::desktop_launch_ready, + local_windows::desktop_launch_next_folder, + local_windows::desktop_launch_acknowledge_folder, windows_update::install_stable_update, workspace_open::open_workspace_target, set_workspace_menu_enabled @@ -769,28 +1081,28 @@ fn main() { | "open-workspace-editor-cursor" | "open-workspace-editor-zed" | "open-workspace-editor-vscodium") => { - if let Some(window) = app_handle.get_webview_window("main") { - if action.starts_with("open-workspace-") - && !window.is_focused().unwrap_or(false) - { - return; - } + if let Some(window) = local_windows::focused_local_window(app_handle) { let _ = window.emit("menu:action", action); } } + "new-window" => { + if let Err(error) = local_windows::create_new_window(app_handle) { + eprintln!("[tauri] failed to create local window: {error}"); + } + } "quit" => { - app_handle.exit(0); + shutdown::request(app_handle.clone()); } // View menu "reload" => { - reload_main_window(app_handle); + reload_target_window(app_handle); } "force_reload" => { - force_reload_main_window(app_handle); + force_reload_target_window(app_handle); } "toggle_devtools" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { if window.is_devtools_open() { window.close_devtools(); } else { @@ -799,18 +1111,21 @@ fn main() { } } "reset_zoom" => { - client_state::set_main_window_zoom( - app_handle, - client_state::DEFAULT_ZOOM_LEVEL, - ); + if let Some(window) = local_windows::targeted_window(app_handle) { + set_target_zoom(app_handle, &window, client_state::DEFAULT_ZOOM_LEVEL); + } } "zoom_in" => { - let zoom_level = client_state::main_window_zoom(app_handle); - client_state::set_main_window_zoom(app_handle, zoom_level + ZOOM_STEP); + if let Some(window) = local_windows::targeted_window(app_handle) { + let zoom = target_zoom(app_handle, &window); + set_target_zoom(app_handle, &window, zoom + ZOOM_STEP); + } } "zoom_out" => { - let zoom_level = client_state::main_window_zoom(app_handle); - client_state::set_main_window_zoom(app_handle, zoom_level - ZOOM_STEP); + if let Some(window) = local_windows::targeted_window(app_handle) { + let zoom = target_zoom(app_handle, &window); + set_target_zoom(app_handle, &window, zoom - ZOOM_STEP); + } } "toggle_fullscreen" => { @@ -819,17 +1134,17 @@ fn main() { // Window menu "minimize" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let _ = window.minimize(); } } "zoom" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let _ = window.maximize(); } } "close_window" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let _ = window.close(); } } @@ -839,7 +1154,7 @@ fn main() { { let app_handle = app_handle.clone(); tauri::async_runtime::spawn(run_update_with_fallback( - windows_update::install_stable_update(), + windows_update::install_stable_update_impl(), move || open_releases_page(&app_handle), )); } @@ -849,7 +1164,7 @@ fn main() { } // App menu (macOS) "hide" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let _ = window.hide(); } } @@ -867,7 +1182,7 @@ fn main() { } } }) - .build(tauri::generate_context!()) + .build(context) .expect("error while building tauri application") .run(|app_handle, event| match event { tauri::RunEvent::ExitRequested { api, .. } => { @@ -898,38 +1213,64 @@ fn main() { } => { emit_window_event(&app_handle, &label, "desktop:folder-drag-leave"); } + tauri::RunEvent::WindowEvent { + label, + event: tauri::WindowEvent::Focused(focused), + .. + } => { + if focused && identity::local_window_id(&label).is_ok() { + app_handle + .state::() + .mark_focused(&app_handle, &label); + } + update_workspace_menu_state(&app_handle); + update_fullscreen_shortcut(&app_handle); + } tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { api, .. }, .. } => { - if label == "main" { - if shutdown::main_window_close_allowed(&app_handle) { - return; - } - let final_window = app_handle.webview_windows().len() == 1; - if shutdown::exit_allowed(&app_handle) { - return; - } - api.prevent_close(); - if final_window { - shutdown::request(app_handle.clone()); - } else { - shutdown::request_main_window_close(app_handle.clone()); - } + if shutdown::exit_allowed(&app_handle) { return; } - // Let windows close normally. App shutdown is handled only after the - // last window is actually gone so remote windows can outlive `main`. + let final_window = app_handle.webview_windows().len() == 1; + if final_window { + api.prevent_close(); + shutdown::request(app_handle.clone()); + return; + } + if identity::local_window_id(&label).is_ok() + && !shutdown::local_window_close_allowed(&app_handle, &label) + { + api.prevent_close(); + shutdown::request_local_window_close(app_handle.clone(), label); + } } tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::Destroyed, .. } => { - if label == "main" { - shutdown::main_window_destroyed(app_handle.clone()); + if let Ok(window_id) = identity::local_window_id(&label) { + app_handle + .state::() + .remove_runtime(&label); + if let Some(state) = app_handle.try_state::() { + state.unregister_window(&window_id); + } } + if let Ok(mut wake) = app_handle.state::().wake_lock.lock() { + wake.labels.remove(&label); + if wake.labels.is_empty() { + 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; } @@ -938,6 +1279,18 @@ fn main() { // truly exiting. shutdown::request(app_handle.clone()); } + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { .. } => { + let reopened = app_handle + .state::() + .mru_label() + .is_some_and(|label| local_windows::focus(app_handle, &label)); + if !reopened { + if let Err(error) = local_windows::create_new_window(app_handle) { + eprintln!("[tauri] failed to create local window on reopen: {error}"); + } + } + } _ => {} }); } @@ -975,7 +1328,13 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { submenus.push(app_menu); } - // File menu - create New Instance with accelerator + let new_window_item = MenuItem::with_id( + app, + "new-window", + "New Window", + true, + Some("CmdOrCtrl+Shift+N"), + )?; let new_instance_item = MenuItem::with_id( app, "new-instance", @@ -1016,8 +1375,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { let file_menu = if is_mac { SubmenuBuilder::new(app, "File") - .item(&new_instance_item) - .separator() .item(&open_folder_item) .item(&open_terminal_item) .item(&open_editor_menu) @@ -1026,8 +1383,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { .build()? } else { SubmenuBuilder::new(app, "File") - .item(&new_instance_item) - .separator() .item(&open_folder_item) .item(&open_terminal_item) .item(&open_editor_menu) @@ -1120,6 +1475,9 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { // Window menu let window_menu = if is_linux { SubmenuBuilder::new(app, "Window") + .item(&new_window_item) + .item(&new_instance_item) + .separator() .text("minimize", "Minimize") .text("zoom", "Zoom") .separator() @@ -1127,11 +1485,17 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { .build()? } else if is_mac { SubmenuBuilder::new(app, "Window") + .item(&new_window_item) + .item(&new_instance_item) + .separator() .minimize() .maximize() .build()? } else { SubmenuBuilder::new(app, "Window") + .item(&new_window_item) + .item(&new_instance_item) + .separator() .minimize() .maximize() .separator() @@ -1193,8 +1557,9 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada #[cfg(test)] mod menu_tests { use super::{ - build_about_metadata, run_update_with_fallback, should_allow_registered_origin, - RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, + build_about_metadata, is_allowed_local_origin, run_update_with_fallback, + should_allow_registered_origin, should_recreate_remote_window, RemoteProfileIdentity, + WakeLockState, RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, }; use serde_json::json; use std::sync::atomic::{AtomicBool, Ordering}; @@ -1260,6 +1625,33 @@ mod menu_tests { .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(); + state.labels.insert("local-a".into()); + state.labels.insert("local-b".into()); + state.labels.remove("local-a"); + assert_eq!( + state.labels, + std::collections::HashSet::from(["local-b".to_string()]) + ); } #[test] @@ -1278,4 +1670,34 @@ mod menu_tests { &Url::parse("about:blank").unwrap() )); } + + #[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 local_navigation_rejects_remote_and_unrelated_loopback_origins() { + let managed = "http://127.0.0.1:43123"; + assert!(is_allowed_local_origin( + &Url::parse("http://127.0.0.1:43123/workspace").unwrap(), + Some(managed), + )); + assert!(!is_allowed_local_origin( + &Url::parse("http://127.0.0.1:43124/workspace").unwrap(), + Some(managed), + )); + assert!(!is_allowed_local_origin( + &Url::parse("https://remote.example/workspace").unwrap(), + Some(managed), + )); + } } diff --git a/packages/tauri-app/src-tauri/src/shutdown.rs b/packages/tauri-app/src-tauri/src/shutdown.rs index 037df8ae..21b1078d 100644 --- a/packages/tauri-app/src-tauri/src/shutdown.rs +++ b/packages/tauri-app/src-tauri/src/shutdown.rs @@ -1,4 +1,5 @@ -use crate::{client_state, AppState}; +use crate::{client_state, local_windows::LocalWindows, AppState}; +use std::collections::{HashMap, HashSet}; #[cfg(windows)] use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; @@ -9,21 +10,26 @@ use tauri::{AppHandle, Emitter, Manager}; const RENDERER_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); const SHUTDOWN_STOP_ATTEMPTS: usize = 2; +const FLUSH_CANCELLED_EVENT: &str = "client-state:flush-cancelled"; #[cfg(windows)] const WINDOWS_SESSION_END_TIMEOUT: Duration = Duration::from_secs(5); -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(crate) enum ShutdownPhase { - #[default] - Idle, - WaitingForShutdownRenderer, - CleanupInProgress, - CleanupBlocked, - ExitAllowed, - WaitingForMainWindowRenderer, - FlushingMainWindow, - FlushingMainWindowForShutdown, - MainWindowCloseAllowed, +#[derive(Clone, Debug)] +struct PendingClose { + window_id: String, + generation: u64, + persisted: bool, +} + +#[derive(Default)] +struct ShutdownState { + next_generation: u64, + local_closes: HashMap, + close_allowed: HashSet, + global_pending: HashMap, + shutdown_started: bool, + cleanup_started: bool, + exit_allowed: bool, } #[derive(Default)] @@ -33,45 +39,142 @@ pub(crate) struct ShutdownCoordinator { windows_session_end_started: AtomicBool, } -#[derive(Default)] -struct ShutdownState { - phase: ShutdownPhase, - flush_generation: u64, - active_flush_generation: Option, -} - impl ShutdownCoordinator { - fn apply(&self, event: ShutdownEvent) -> (ShutdownAction, Option) { - let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - if let Some(generation) = event.renderer_generation() { - if state.active_flush_generation != Some(generation) { - return (ShutdownAction::None, None); - } + fn begin_local_close(&self, label: String, window_id: String, persisted: bool) -> Option { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.shutdown_started + || state.local_closes.contains_key(&label) + || state.close_allowed.contains(&label) + { + return None; } - let (next, action) = transition(state.phase, event); - state.phase = next; - if action == ShutdownAction::RequestRendererFlush { - state.flush_generation += 1; - state.active_flush_generation = Some(state.flush_generation); - } else if !matches!( - next, - ShutdownPhase::WaitingForShutdownRenderer | ShutdownPhase::WaitingForMainWindowRenderer - ) { - state.active_flush_generation = None; - } - (action, state.active_flush_generation) + state.next_generation += 1; + let generation = state.next_generation; + state.local_closes.insert( + label, + PendingClose { + window_id, + generation, + persisted, + }, + ); + Some(generation) } - fn phase(&self) -> ShutdownPhase { + fn acknowledge_local( + &self, + label: &str, + window_id: &str, + generation: u64, + ) -> Option { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let pending = state.local_closes.get(label)?; + if pending.window_id != window_id || pending.generation != generation { + return None; + } + state.local_closes.remove(label) + } + + fn begin_shutdown( + &self, + labels: impl IntoIterator, + ) -> Option> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.shutdown_started { + return None; + } + state.shutdown_started = true; + state.local_closes.clear(); + let mut requests = Vec::new(); + for label in labels { + state.next_generation += 1; + let generation = state.next_generation; + state.global_pending.insert(label.clone(), generation); + requests.push((label, generation)); + } + Some(requests) + } + + fn acknowledge_global(&self, label: &str, generation: u64) -> bool { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.global_pending.get(label).copied() != Some(generation) { + return false; + } + state.global_pending.remove(label); + state.global_pending.is_empty() + } + + fn begin_cleanup(&self, deadline_reached: bool) -> bool { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if !state.shutdown_started + || state.cleanup_started + || (!deadline_reached && !state.global_pending.is_empty()) + { + return false; + } + state.global_pending.clear(); + state.cleanup_started = true; + true + } + + fn cleanup_failed(&self) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.cleanup_started = false; + state.shutdown_started = false; + state.global_pending.clear(); + state.close_allowed.clear(); + } + + fn commit_local_close(&self, label: &str) -> bool { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.shutdown_started { + return false; + } + state.close_allowed.insert(label.to_string()); + true + } + + fn rollback_local_close(&self, label: &str) { self.state .lock() - .unwrap_or_else(|err| err.into_inner()) - .phase + .unwrap_or_else(|error| error.into_inner()) + .close_allowed + .remove(label); } - fn with_navigation(&self, operation: impl FnOnce() -> T) -> Option { - let state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - (state.phase == ShutdownPhase::Idle).then(operation) + fn close_allowed(&self, label: &str) -> bool { + self.state + .lock() + .map(|state| state.close_allowed.contains(label)) + .unwrap_or(false) + } + + fn exit_allowed(&self) -> bool { + self.state + .lock() + .map(|state| state.exit_allowed) + .unwrap_or(false) + } + + fn allow_exit(&self) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .exit_allowed = true; + } + + fn navigation_allowed(&self) -> bool { + self.state + .lock() + .map(|state| !state.shutdown_started) + .unwrap_or(false) + } + + fn shutdown_started(&self) -> bool { + self.state + .lock() + .map(|state| state.shutdown_started) + .unwrap_or(true) } #[cfg(windows)] @@ -80,143 +183,165 @@ impl ShutdownCoordinator { .windows_session_end_started .swap(true, Ordering::SeqCst) } +} - #[cfg(windows)] - fn complete_windows_session_end(&self) { - let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - state.phase = ShutdownPhase::ExitAllowed; - state.active_flush_generation = None; +fn emit_flush(app: &AppHandle, label: &str, generation: u64) -> bool { + app.get_webview_window(label).is_some_and(|window| { + window + .emit( + "client-state:flush-requested", + client_state::RendererFlushRequest { generation }, + ) + .is_ok() + }) +} + +fn emit_flush_cancelled(app: &AppHandle, label: &str) { + if let Some(window) = app.get_webview_window(label) { + let _ = window.emit(FLUSH_CANCELLED_EVENT, ()); } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShutdownEvent { - BeginShutdown, - BeginMainWindowClose, - RendererFlushed(u64), - RendererUnavailable, - RendererTimeout(u64), - MainWindowFlushed, - MainWindowCloseFailed, - MainWindowDestroyed, - CleanupFinished, - CleanupFailed, -} - -impl ShutdownEvent { - fn renderer_generation(self) -> Option { - match self { - Self::RendererFlushed(generation) | Self::RendererTimeout(generation) => { - Some(generation) - } - _ => None, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShutdownAction { - None, - RequestRendererFlush, - FlushMainWindow, - CloseMainWindow, - StartCleanup, -} - -fn transition(phase: ShutdownPhase, event: ShutdownEvent) -> (ShutdownPhase, ShutdownAction) { - use ShutdownAction::*; - use ShutdownEvent::*; - use ShutdownPhase::*; - - match (phase, event) { - (Idle, BeginShutdown) => (WaitingForShutdownRenderer, RequestRendererFlush), - ( - WaitingForShutdownRenderer, - RendererFlushed(_) | RendererUnavailable | RendererTimeout(_) | MainWindowDestroyed, - ) => (CleanupInProgress, StartCleanup), - (Idle, BeginMainWindowClose) => (WaitingForMainWindowRenderer, RequestRendererFlush), - (WaitingForMainWindowRenderer, RendererFlushed(_) | RendererTimeout(_)) => { - (FlushingMainWindow, FlushMainWindow) - } - (WaitingForMainWindowRenderer, RendererUnavailable | MainWindowDestroyed) => (Idle, None), - (WaitingForMainWindowRenderer, BeginShutdown) => (WaitingForShutdownRenderer, None), - (FlushingMainWindow, BeginShutdown) => (FlushingMainWindowForShutdown, None), - (FlushingMainWindow, MainWindowFlushed) => (MainWindowCloseAllowed, CloseMainWindow), - (FlushingMainWindow, MainWindowDestroyed) => (Idle, None), - (FlushingMainWindowForShutdown, MainWindowFlushed | MainWindowDestroyed) => { - (CleanupInProgress, StartCleanup) - } - (MainWindowCloseAllowed, MainWindowDestroyed | MainWindowCloseFailed) => (Idle, None), - (MainWindowCloseAllowed, BeginShutdown) => (CleanupInProgress, StartCleanup), - (CleanupInProgress, CleanupFinished) => (ExitAllowed, None), - (CleanupInProgress, CleanupFailed) => (CleanupBlocked, None), - (CleanupBlocked, BeginShutdown) => (CleanupInProgress, StartCleanup), - _ => (phase, None), - } -} - -fn apply_event(app: AppHandle, event: ShutdownEvent) { - let (action, generation) = app.state::().apply(event); - - match action { - ShutdownAction::RequestRendererFlush => request_renderer_flush(app, generation.unwrap()), - ShutdownAction::FlushMainWindow => flush_main_window(app), - ShutdownAction::CloseMainWindow => close_main_window(app), - ShutdownAction::StartCleanup => start_cleanup(app), - ShutdownAction::None => {} - } -} - -fn request_renderer_flush(app: AppHandle, generation: u64) { - let Some(window) = app.get_webview_window("main") else { - apply_event(app, ShutdownEvent::RendererUnavailable); +pub(crate) fn request_local_window_close(app: AppHandle, label: String) { + let Some(record) = app.state::().record(&label) else { return; }; - if let Err(err) = window.emit( - "client-state:flush-requested", - client_state::RendererFlushRequest { generation }, - ) { - eprintln!("[client-state] failed to request renderer shutdown flush: {err}"); + let Some(generation) = app.state::().begin_local_close( + label.clone(), + record.id.clone(), + record.persisted, + ) else { + return; + }; + if !emit_flush(&app, &label, generation) { + finish_local_close(app, label, record.id, generation); + return; } std::thread::spawn(move || { std::thread::sleep(RENDERER_FLUSH_TIMEOUT); - apply_event(app, ShutdownEvent::RendererTimeout(generation)); + finish_local_close(app, label, record.id, generation); }); } -fn flush_main_window(app: AppHandle) { - std::thread::spawn(move || { - client_state::capture_and_flush_main_window(&app); - apply_event(app, ShutdownEvent::MainWindowFlushed); - }); -} - -fn close_main_window(app: AppHandle) { - let Some(window) = app.get_webview_window("main") else { - apply_event(app, ShutdownEvent::MainWindowDestroyed); +fn finish_local_close(app: AppHandle, label: String, window_id: String, generation: u64) { + let Some(pending) = app + .state::() + .acknowledge_local(&label, &window_id, generation) + else { return; }; - if let Err(err) = window.close() { - eprintln!("[client-state] failed to close main window after state flush: {err}"); - apply_event(app, ShutdownEvent::MainWindowCloseFailed); - } -} - -fn start_cleanup(app: AppHandle) { std::thread::spawn(move || { - let result = cleanup(&app, true); - match result { - Ok(()) => { - apply_event(app.clone(), ShutdownEvent::CleanupFinished); - app.exit(0); - } - Err(err) => { - eprintln!( - "[tauri] shutdown cleanup remains unconfirmed after {SHUTDOWN_STOP_ATTEMPTS} attempts: {err}; keeping the app alive for a later quit retry" - ); - apply_event(app, ShutdownEvent::CleanupFailed); + client_state::capture_and_flush_window(&app, &label); + if !app + .state::() + .commit_local_close(&label) + { + return; + } + if pending.persisted { + if let Some(state) = app.try_state::() { + if let Err(error) = state.remove_window(&pending.window_id) { + eprintln!("[client-state] failed to remove closed window: {error}"); + app.state::() + .rollback_local_close(&label); + emit_flush_cancelled(&app, &label); + return; + } } } + let close_app = app.clone(); + let close_label = label.clone(); + if app + .run_on_main_thread(move || { + if let Some(window) = close_app.get_webview_window(&close_label) { + if window.close().is_err() { + close_app + .state::() + .rollback_local_close(&close_label); + emit_flush_cancelled(&close_app, &close_label); + } + } + }) + .is_err() + { + app.state::() + .rollback_local_close(&label); + emit_flush_cancelled(&app, &label); + } + }); +} + +pub(crate) fn request(app: AppHandle) { + let labels = app + .state::() + .records() + .into_iter() + .map(|record| record.label) + .collect::>(); + let Some(requests) = app.state::().begin_shutdown(labels) else { + start_cleanup(app, false); + return; + }; + for (label, generation) in &requests { + if !emit_flush(&app, label, *generation) { + app.state::() + .acknowledge_global(label, *generation); + } + } + if requests.is_empty() + || requests.iter().all(|(label, generation)| { + app.state::() + .state + .lock() + .ok() + .is_some_and(|state| state.global_pending.get(label) != Some(generation)) + }) + { + start_cleanup(app, false); + return; + } + std::thread::spawn(move || { + std::thread::sleep(RENDERER_FLUSH_TIMEOUT); + start_cleanup(app, true); + }); +} + +pub(crate) fn renderer_flushed(app: AppHandle, label: String, window_id: String, generation: u64) { + if app + .state::() + .acknowledge_global(&label, generation) + { + start_cleanup(app, false); + return; + } + finish_local_close(app, label, window_id, generation); +} + +fn start_cleanup(app: AppHandle, deadline_reached: bool) { + if !app + .state::() + .begin_cleanup(deadline_reached) + { + return; + } + std::thread::spawn(move || { + client_state::capture_and_flush_all_windows(&app); + let result = if let Some(state) = app.try_state::() { + retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { + state.manager.stop().map_err(|error| error.to_string()) + }) + } else { + Ok(()) + }; + if let Err(error) = result { + eprintln!("[tauri] shutdown cleanup remains unconfirmed: {error}"); + app.state::().cleanup_failed(); + crate::local_windows::emit_all(&app, FLUSH_CANCELLED_EVENT, ()); + return; + } + client_state::release(&app); + app.state::().allow_exit(); + app.exit(0); }); } @@ -228,32 +353,28 @@ fn retry_bounded( for attempt in 1..=attempts { match operation() { Ok(()) => return Ok(()), - Err(err) if attempt == attempts => return Err(err), + Err(error) if attempt == attempts => return Err(error), Err(_) => {} } } unreachable!() } -fn cleanup(app: &AppHandle, capture_window: bool) -> Result<(), String> { - if capture_window { - client_state::capture_and_flush_main_window(app); - } else { - client_state::flush_and_release_without_window_capture(app); - } - if let Some(state) = app.try_state::() { - retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { - state.manager.stop().map_err(|err| err.to_string()) - })?; - } - if capture_window { - client_state::release(app); - } - Ok(()) +pub(crate) fn with_navigation_authority( + app: &AppHandle, + operation: impl FnOnce() -> T, +) -> Option { + app.state::() + .navigation_allowed() + .then(operation) } -pub(crate) fn request(app: AppHandle) { - apply_event(app, ShutdownEvent::BeginShutdown); +pub(crate) fn local_window_close_allowed(app: &AppHandle, label: &str) -> bool { + app.state::().close_allowed(label) +} + +pub(crate) fn exit_allowed(app: &AppHandle) -> bool { + app.state::().exit_allowed() } #[cfg(windows)] @@ -264,71 +385,44 @@ pub(crate) fn request_windows_session_end(app: AppHandle) { { return; } - + if app.state::().shutdown_started() { + let deadline = std::time::Instant::now() + WINDOWS_SESSION_END_TIMEOUT; + while !exit_allowed(&app) && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + return; + } + let _ = app + .state::() + .begin_shutdown(std::iter::empty()); let (finished_tx, finished_rx) = std::sync::mpsc::sync_channel(1); let cleanup_app = app.clone(); std::thread::spawn(move || { - // WM_ENDSESSION runs on the window thread. Do not request renderer flushes or - // native window state here: either can marshal back to the blocked thread. The - // bounded fallback therefore persists only state already captured in memory. - let result = cleanup(&cleanup_app, false); - if let Err(err) = &result { - eprintln!("[tauri] Windows session-end cleanup failed: {err}"); - } - cleanup_app - .state::() - .complete_windows_session_end(); + client_state::flush_and_release_without_window_capture(&cleanup_app); + let result = { + cleanup_app + .try_state::() + .map(|state| { + retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { + state.manager.stop().map_err(|error| error.to_string()) + }) + }) + .unwrap_or(Ok(())) + }; + cleanup_app.state::().allow_exit(); let _ = finished_tx.send(result); }); - match finished_rx.recv_timeout(WINDOWS_SESSION_END_TIMEOUT) { - Ok(Ok(())) => {} - Ok(Err(_)) => {} - Err(_) => eprintln!( - "[tauri] Windows session-end cleanup exceeded {:?}; returning control to Windows", + if finished_rx + .recv_timeout(WINDOWS_SESSION_END_TIMEOUT) + .is_err() + { + eprintln!( + "[tauri] Windows session-end cleanup exceeded {:?}", WINDOWS_SESSION_END_TIMEOUT - ), + ); } } -pub(crate) fn request_main_window_close(app: AppHandle) { - apply_event(app, ShutdownEvent::BeginMainWindowClose); -} - -pub(crate) fn renderer_flushed(app: AppHandle, generation: u64) { - apply_event(app, ShutdownEvent::RendererFlushed(generation)); -} - -pub(crate) fn main_window_destroyed(app: AppHandle) { - apply_event(app, ShutdownEvent::MainWindowDestroyed); -} - -fn phase(app: &AppHandle) -> ShutdownPhase { - app.state::().phase() -} - -pub(crate) fn with_navigation_authority( - app: &AppHandle, - operation: impl FnOnce() -> T, -) -> Option { - app.state::() - .with_navigation(operation) -} - -pub(crate) fn main_window_close_allowed(app: &AppHandle) -> bool { - phase(app) == ShutdownPhase::MainWindowCloseAllowed -} - -pub(crate) fn exit_allowed(app: &AppHandle) -> bool { - phase(app) == ShutdownPhase::ExitAllowed -} - -#[cfg(windows)] -fn is_confirmed_windows_session_end(message: u32, wparam: usize) -> bool { - use windows_sys::Win32::UI::WindowsAndMessaging::WM_ENDSESSION; - - message == WM_ENDSESSION && wparam != 0 -} - #[cfg(windows)] struct WindowsSessionEndContext { app: AppHandle, @@ -344,8 +438,9 @@ unsafe extern "system" fn windows_session_end_proc( reference_data: usize, ) -> windows_sys::Win32::Foundation::LRESULT { use windows_sys::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass}; - use windows_sys::Win32::UI::WindowsAndMessaging::{WM_NCDESTROY, WM_QUERYENDSESSION}; - + use windows_sys::Win32::UI::WindowsAndMessaging::{ + WM_ENDSESSION, WM_NCDESTROY, WM_QUERYENDSESSION, + }; if message == WM_NCDESTROY { RemoveWindowSubclass(hwnd, Some(windows_session_end_proc), subclass_id); let result = DefSubclassProc(hwnd, message, wparam, lparam); @@ -354,26 +449,22 @@ unsafe extern "system" fn windows_session_end_proc( )); return result; } - if message == WM_QUERYENDSESSION { return 1; } - - if is_confirmed_windows_session_end(message, wparam) { + if message == WM_ENDSESSION && wparam != 0 { let context = &*(reference_data as *const WindowsSessionEndContext); request_windows_session_end(context.app.clone()); return 0; } - DefSubclassProc(hwnd, message, wparam, lparam) } #[cfg(windows)] pub(crate) fn install_windows_session_end_handler(window: &WebviewWindow) -> Result<(), String> { use windows_sys::Win32::UI::Shell::SetWindowSubclass; - const SUBCLASS_ID: usize = 0x434E_5345; - let hwnd = window.hwnd().map_err(|err| err.to_string())?; + let hwnd = window.hwnd().map_err(|error| error.to_string())?; let context = Box::into_raw(Box::new(WindowsSessionEndContext { app: window.app_handle().clone(), })); @@ -397,214 +488,13 @@ pub(crate) fn schedule_windows_session_end_handler(window: &WebviewWindow) -> Re let window = window.clone(); let app = window.app_handle().clone(); app.run_on_main_thread(move || { - if let Err(err) = install_windows_session_end_handler(&window) { - eprintln!("[client-state] failed to install Windows session-end handler: {err}"); + if let Err(error) = install_windows_session_end_handler(&window) { + eprintln!("[client-state] failed to install Windows session-end handler: {error}"); } }) - .map_err(|err| err.to_string()) + .map_err(|error| error.to_string()) } #[cfg(test)] -mod tests { - use super::*; - use std::sync::{mpsc, Arc}; - - #[test] - fn close_only_flushes_and_closes_without_cleanup() { - let waiting = transition(ShutdownPhase::Idle, ShutdownEvent::BeginMainWindowClose); - assert_eq!( - waiting, - ( - ShutdownPhase::WaitingForMainWindowRenderer, - ShutdownAction::RequestRendererFlush - ) - ); - let flushing = transition(waiting.0, ShutdownEvent::RendererFlushed(1)); - assert_eq!( - flushing, - ( - ShutdownPhase::FlushingMainWindow, - ShutdownAction::FlushMainWindow - ) - ); - assert_eq!( - transition(flushing.0, ShutdownEvent::MainWindowFlushed), - ( - ShutdownPhase::MainWindowCloseAllowed, - ShutdownAction::CloseMainWindow - ) - ); - } - - #[test] - fn shutdown_promotes_each_close_only_phase() { - for (phase, event, expected) in [ - ( - ShutdownPhase::WaitingForMainWindowRenderer, - ShutdownEvent::BeginShutdown, - ( - ShutdownPhase::WaitingForShutdownRenderer, - ShutdownAction::None, - ), - ), - ( - ShutdownPhase::FlushingMainWindow, - ShutdownEvent::BeginShutdown, - ( - ShutdownPhase::FlushingMainWindowForShutdown, - ShutdownAction::None, - ), - ), - ( - ShutdownPhase::FlushingMainWindowForShutdown, - ShutdownEvent::MainWindowFlushed, - ( - ShutdownPhase::CleanupInProgress, - ShutdownAction::StartCleanup, - ), - ), - ( - ShutdownPhase::MainWindowCloseAllowed, - ShutdownEvent::BeginShutdown, - ( - ShutdownPhase::CleanupInProgress, - ShutdownAction::StartCleanup, - ), - ), - ] { - assert_eq!(transition(phase, event), expected); - } - } - - #[test] - fn stale_renderer_completion_cannot_advance_a_later_close() { - let coordinator = ShutdownCoordinator::default(); - let (_, first) = coordinator.apply(ShutdownEvent::BeginMainWindowClose); - assert_eq!(first, Some(1)); - coordinator.apply(ShutdownEvent::RendererFlushed(1)); - coordinator.apply(ShutdownEvent::MainWindowFlushed); - coordinator.apply(ShutdownEvent::MainWindowDestroyed); - let (_, second) = coordinator.apply(ShutdownEvent::BeginMainWindowClose); - assert_eq!(second, Some(2)); - for stale in [ - ShutdownEvent::RendererFlushed(1), - ShutdownEvent::RendererTimeout(1), - ] { - assert_eq!(coordinator.apply(stale), (ShutdownAction::None, None)); - assert_eq!( - coordinator.phase(), - ShutdownPhase::WaitingForMainWindowRenderer - ); - } - assert_eq!( - coordinator.apply(ShutdownEvent::RendererTimeout(2)).0, - ShutdownAction::FlushMainWindow - ); - } - - #[test] - fn cleanup_completion_permanently_allows_exit() { - let completed = transition( - ShutdownPhase::CleanupInProgress, - ShutdownEvent::CleanupFinished, - ); - assert_eq!( - completed, - (ShutdownPhase::ExitAllowed, ShutdownAction::None) - ); - assert_eq!( - transition(completed.0, ShutdownEvent::BeginShutdown), - (ShutdownPhase::ExitAllowed, ShutdownAction::None) - ); - } - - #[test] - fn failed_cleanup_stays_alive_and_accepts_a_later_retry() { - assert_eq!( - transition( - ShutdownPhase::CleanupInProgress, - ShutdownEvent::CleanupFailed - ), - (ShutdownPhase::CleanupBlocked, ShutdownAction::None) - ); - assert_eq!( - transition(ShutdownPhase::CleanupBlocked, ShutdownEvent::BeginShutdown), - ( - ShutdownPhase::CleanupInProgress, - ShutdownAction::StartCleanup - ) - ); - } - - #[test] - fn shutdown_stop_retries_are_bounded() { - let mut attempts = 0; - retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { - attempts += 1; - (attempts == SHUTDOWN_STOP_ATTEMPTS).then_some(()).ok_or(()) - }) - .unwrap(); - assert_eq!(attempts, SHUTDOWN_STOP_ATTEMPTS); - - let mut failures = 0; - assert!(retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { - failures += 1; - Err::<(), _>("unconfirmed") - }) - .is_err()); - assert_eq!(failures, SHUTDOWN_STOP_ATTEMPTS); - } - - #[test] - fn shutdown_waits_for_the_final_navigation_invocation() { - let coordinator = Arc::new(ShutdownCoordinator::default()); - let navigating = Arc::clone(&coordinator); - let (started_tx, started_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - let navigation = std::thread::spawn(move || { - navigating.with_navigation(|| { - started_tx.send(()).unwrap(); - release_rx.recv().unwrap(); - }) - }); - started_rx.recv().unwrap(); - let shutting_down = Arc::clone(&coordinator); - let (finished_tx, finished_rx) = mpsc::channel(); - let shutdown = std::thread::spawn(move || { - shutting_down.apply(ShutdownEvent::BeginShutdown); - finished_tx.send(()).unwrap(); - }); - assert!(finished_rx.recv_timeout(Duration::from_millis(20)).is_err()); - release_tx.send(()).unwrap(); - navigation.join().unwrap(); - finished_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - shutdown.join().unwrap(); - assert_eq!( - coordinator.phase(), - ShutdownPhase::WaitingForShutdownRenderer - ); - assert!(coordinator.with_navigation(|| ()).is_none()); - } - - #[cfg(windows)] - #[test] - fn windows_session_end_is_bounded_and_starts_once() { - let coordinator = ShutdownCoordinator::default(); - assert!(coordinator.begin_windows_session_end()); - assert!(!coordinator.begin_windows_session_end()); - assert!(WINDOWS_SESSION_END_TIMEOUT <= Duration::from_secs(5)); - } - - #[cfg(windows)] - #[test] - fn only_confirmed_end_session_starts_shutdown() { - use windows_sys::Win32::UI::WindowsAndMessaging::{ - WM_CLOSE, WM_ENDSESSION, WM_QUERYENDSESSION, - }; - - assert!(!is_confirmed_windows_session_end(WM_QUERYENDSESSION, 0)); - assert!(is_confirmed_windows_session_end(WM_ENDSESSION, 1)); - assert!(!is_confirmed_windows_session_end(WM_ENDSESSION, 0)); - assert!(!is_confirmed_windows_session_end(WM_CLOSE, 0)); - } -} +#[path = "shutdown_tests.rs"] +mod tests; diff --git a/packages/tauri-app/src-tauri/src/shutdown_tests.rs b/packages/tauri-app/src-tauri/src/shutdown_tests.rs new file mode 100644 index 00000000..68c18eff --- /dev/null +++ b/packages/tauri-app/src-tauri/src/shutdown_tests.rs @@ -0,0 +1,78 @@ +use super::*; + +#[test] +fn close_generations_are_label_isolated_and_stale_ack_is_ignored() { + let coordinator = ShutdownCoordinator::default(); + let first = coordinator + .begin_local_close("local-a".into(), "a".into(), true) + .unwrap(); + let second = coordinator + .begin_local_close("local-b".into(), "b".into(), true) + .unwrap(); + assert!(coordinator + .acknowledge_local("local-a", "a", second) + .is_none()); + assert!(coordinator + .acknowledge_local("local-a", "a", first) + .is_some()); + assert!(coordinator + .acknowledge_local("local-b", "b", first) + .is_none()); + assert!(coordinator + .acknowledge_local("local-b", "b", second) + .is_some()); +} + +#[test] +fn final_shutdown_and_cleanup_start_once() { + let coordinator = ShutdownCoordinator::default(); + let requests = coordinator + .begin_shutdown(["local-a".to_string(), "local-b".to_string()]) + .unwrap(); + assert!(coordinator.begin_shutdown(std::iter::empty()).is_none()); + assert!(!coordinator.acknowledge_global(&requests[0].0, requests[0].1)); + assert!(coordinator.acknowledge_global(&requests[1].0, requests[1].1)); + assert!(coordinator.begin_cleanup(false)); + assert!(!coordinator.begin_cleanup(false)); + coordinator.cleanup_failed(); + assert!(!coordinator.shutdown_started()); + assert!(coordinator.navigation_allowed()); + assert!(coordinator.begin_shutdown(std::iter::empty()).is_some()); + assert!(coordinator.begin_cleanup(false)); +} + +#[test] +fn failed_shutdown_paths_emit_the_renderer_resume_event() { + let source = include_str!("shutdown.rs"); + assert_eq!(FLUSH_CANCELLED_EVENT, "client-state:flush-cancelled"); + assert!(source.matches("emit_flush_cancelled(&app, &label)").count() >= 2); + assert!(source.contains("emit_all(&app, FLUSH_CANCELLED_EVENT")); +} + +#[test] +fn global_shutdown_wins_an_uncommitted_local_close() { + let coordinator = ShutdownCoordinator::default(); + let generation = coordinator + .begin_local_close("local-a".into(), "a".into(), true) + .unwrap(); + assert!(coordinator + .acknowledge_local("local-a", "a", generation) + .is_some()); + coordinator.begin_shutdown(["local-a".to_string()]).unwrap(); + assert!(!coordinator.commit_local_close("local-a")); +} + +#[test] +fn bounded_retry_stops_after_success() { + let mut calls = 0; + retry_bounded(2, || { + calls += 1; + if calls == 1 { + Err("retry") + } else { + Ok(()) + } + }) + .unwrap(); + assert_eq!(calls, 2); +} diff --git a/packages/tauri-app/src-tauri/src/windows_update.rs b/packages/tauri-app/src-tauri/src/windows_update.rs index 7d4ce2cb..6d96c88b 100644 --- a/packages/tauri-app/src-tauri/src/windows_update.rs +++ b/packages/tauri-app/src-tauri/src/windows_update.rs @@ -4,7 +4,15 @@ use std::process::Command; use std::os::windows::process::CommandExt; #[tauri::command] -pub async fn install_stable_update() -> Result<(), String> { +pub async fn install_stable_update( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::AppState>, +) -> Result<(), String> { + crate::require_local_app_window(&window, &state)?; + install_stable_update_impl().await +} + +pub(crate) async fn install_stable_update_impl() -> Result<(), String> { tauri::async_runtime::spawn_blocking(|| { let mut command = Command::new("winget"); command.args([ diff --git a/packages/tauri-app/src-tauri/src/workspace_open.rs b/packages/tauri-app/src-tauri/src/workspace_open.rs index fbb41ce2..d2e720f6 100644 --- a/packages/tauri-app/src-tauri/src/workspace_open.rs +++ b/packages/tauri-app/src-tauri/src/workspace_open.rs @@ -574,9 +574,7 @@ pub async fn open_workspace_target( path: Option, editor: Option, ) -> Result<(), String> { - if window.label() != "main" { - return Err("Workspace open requests are limited to the local main window".into()); - } + crate::identity::local_window_id(window.label())?; let config = state .manager .local_cli_access() diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index bed5e90d..bafeeec5 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -11,25 +11,7 @@ "app": { "enableGTKAppId": true, "withGlobalTauri": true, - "windows": [ - { - "label": "main", - "title": "CodeNomad", - "url": "loading.html", - "width": 1400, - "height": 900, - "minWidth": 800, - "minHeight": 600, - "center": true, - "resizable": true, - "fullscreen": false, - "decorations": true, - "theme": "Dark", - "backgroundColor": "#1a1a1a", - "zoomHotkeysEnabled": true, - "visible": false - } - ], + "windows": [], "security": { "assetProtocol": { "scope": [ diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2288ccad..56cba909 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -5,6 +5,7 @@ import useMediaQuery from "@suid/material/useMediaQuery" import { Minimize2 } from "lucide-solid" import AlertDialog from "./components/alert-dialog" import FolderSelectionView from "./components/folder-selection-view" +import { useDesktopFolderLaunch } from "./lib/hooks/use-electron-folder-launch" import { showConfirmDialog } from "./stores/alerts" import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" @@ -73,6 +74,7 @@ import { activeAppTab, activeAppTabId, appTabs, + closeInstanceTab, ensureActiveAppTab, getAdjacentAppTabId, getAppTabById, @@ -404,9 +406,9 @@ const App: Component = () => { return recent?.projectName?.trim() || getPathBasename(folderPath) } - async function handleSelectFolder(folderPath: string) { + async function handleSelectFolder(folderPath: string): Promise { if (!folderPath) { - return + return false } const selectedBinary = serverSettings().opencodeBinary || "opencode2" @@ -421,7 +423,7 @@ const App: Component = () => { selectInstanceTab(result.instanceId) setShowFolderSelection(false) log.info("Selected reused instance", { instanceId: result.instanceId, folderPath }) - return + return true } selectInstanceTab(result.instanceId) @@ -431,6 +433,7 @@ const App: Component = () => { instanceId: result.instanceId, port: instances().get(result.instanceId)?.port, }) + return true } catch (error) { const message = formatLaunchErrorMessage( error, @@ -440,11 +443,14 @@ const App: Component = () => { const missingBinary = isMissingBinaryMessage(message) showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary }) log.error("Failed to create instance", error) + return false } finally { setIsSelectingFolder(false) } } + useDesktopFolderLaunch(handleSelectFolder) + function handleSelectExistingInstance(instanceId: string, recentPath: string) { const instance = instances().get(instanceId) if (!instance) return @@ -489,14 +495,16 @@ const App: Component = () => { } async function handleDisconnectedInstanceClose() { + const instanceId = disconnectedInstance()?.id try { await acknowledgeDisconnectedInstance() + if (instanceId) closeInstanceTab(instanceId) } catch (error) { log.error("Failed to finalize disconnected instance", error) } } - async function handleCloseInstance(instanceId: string) { + async function handleStopInstance(instanceId: string) { const confirmed = await showConfirmDialog( t("app.stopInstance.confirmMessage"), { @@ -509,7 +517,7 @@ const App: Component = () => { if (!confirmed) return - stopInstance(instanceId) + await stopInstance(instanceId) } async function handleNewSession(instanceId: string) { @@ -549,7 +557,7 @@ const App: Component = () => { const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId() if (tab.kind === "instance") { - await handleCloseInstance(tab.instance.id) + closeInstanceTab(tab.instance.id) } else { closeSidecarTab(tab.sidecarTab.token) } @@ -590,7 +598,7 @@ const App: Component = () => { setToolInputsVisibility, handleNewInstanceRequest, handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""), - handleCloseInstance, + handleStopInstance, handleNewSession, handleCloseSession, getActiveInstance: activeInstance, @@ -601,7 +609,6 @@ const App: Component = () => { setEscapeInDebounce, handleNewInstanceRequest, handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""), - handleCloseInstance, handleNewSession, handleCloseSession, showFolderSelection, diff --git a/packages/ui/src/components/browser-frame-security.test.ts b/packages/ui/src/components/browser-frame-security.test.ts new file mode 100644 index 00000000..3bf89507 --- /dev/null +++ b/packages/ui/src/components/browser-frame-security.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { getBrowserFramePolicy } from "./browser-frame-security.ts" + +describe("getBrowserFramePolicy", () => { + it("isolates local native frames from the parent bridge", () => { + for (const host of ["electron", "tauri"] as const) { + const policy = getBrowserFramePolicy({ host, windowContext: "local" }) + const permissions = new Set(policy.sandbox?.split(" ")) + + assert.deepEqual(permissions, new Set(["allow-scripts", "allow-forms", "allow-modals", "allow-popups", "allow-downloads"])) + assert.equal(permissions.has("allow-same-origin"), false) + assert.equal([...permissions].some((permission) => permission.startsWith("allow-top-navigation")), false) + assert.equal(policy.canInspectDom, false) + } + }) + + it("preserves same-origin DOM inspection in standalone web mode", () => { + assert.deepEqual(getBrowserFramePolicy({ host: "web", windowContext: "remote" }), { + sandbox: undefined, + canInspectDom: true, + }) + }) +}) diff --git a/packages/ui/src/components/browser-frame-security.ts b/packages/ui/src/components/browser-frame-security.ts new file mode 100644 index 00000000..bcf9e528 --- /dev/null +++ b/packages/ui/src/components/browser-frame-security.ts @@ -0,0 +1,11 @@ +import type { RuntimeEnvironment } from "../lib/runtime-env" + +const NATIVE_FRAME_SANDBOX = "allow-scripts allow-forms allow-modals allow-popups allow-downloads" + +export function getBrowserFramePolicy(environment: Pick) { + const sandboxed = environment.windowContext === "local" && (environment.host === "electron" || environment.host === "tauri") + return { + sandbox: sandboxed ? NATIVE_FRAME_SANDBOX : undefined, + canInspectDom: !sandboxed, + } +} diff --git a/packages/ui/src/components/browser-frame.tsx b/packages/ui/src/components/browser-frame.tsx index 48c374b1..32994eac 100644 --- a/packages/ui/src/components/browser-frame.tsx +++ b/packages/ui/src/components/browser-frame.tsx @@ -1,5 +1,7 @@ import { ArrowLeft, ArrowRight, ChevronDown, Expand, MessageSquarePlus, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" import { Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js" +import { runtimeEnv } from "../lib/runtime-env" +import { getBrowserFramePolicy } from "./browser-frame-security" export interface BrowserFrameElementTarget { pagePath: string @@ -89,6 +91,7 @@ function getElementSelector(element: Element): string { } export const BrowserFrame: Component = (props) => { + const framePolicy = getBrowserFramePolicy(runtimeEnv) const [frameSrc, setFrameSrc] = createSignal(props.initialUrl) const [pathInput, setPathInput] = createSignal("/") const [viewportPreset, setViewportPreset] = createSignal("responsive") @@ -98,7 +101,7 @@ export const BrowserFrame: Component = (props) => { let frameWrapRef: HTMLDivElement | undefined let cleanupFrameListeners: (() => void) | null = null - const canComment = createMemo(() => Boolean(props.onToggleCommentMode && props.onCommentTarget)) + const canComment = createMemo(() => framePolicy.canInspectDom && Boolean(props.onToggleCommentMode && props.onCommentTarget)) const viewport = createMemo(() => VIEWPORT_PRESETS[viewportPreset()]) const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive") const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0]) @@ -161,7 +164,7 @@ export const BrowserFrame: Component = (props) => { cleanupFrameListeners = null setHighlight(null) - if (!props.commentMode || !iframeRef?.contentDocument || !iframeRef.contentWindow || !frameWrapRef) return + if (!framePolicy.canInspectDom || !props.commentMode || !iframeRef?.contentDocument || !iframeRef.contentWindow || !frameWrapRef) return const doc = iframeRef.contentDocument const frameWindow = iframeRef.contentWindow @@ -357,6 +360,7 @@ export const BrowserFrame: Component = (props) => { margin: viewport().width ? "0 auto" : "0", }} referrerPolicy="same-origin" + sandbox={framePolicy.sandbox} onLoad={syncPathInputFromFrame} /> diff --git a/packages/ui/src/components/session-picker.tsx b/packages/ui/src/components/session-picker.tsx index e44d1024..a28b2ae1 100644 --- a/packages/ui/src/components/session-picker.tsx +++ b/packages/ui/src/components/session-picker.tsx @@ -2,7 +2,8 @@ import { Component, createSignal, Show, For, createEffect } from "solid-js" import { Dialog } from "@kobalte/core/dialog" import { isSelectablePrimaryAgent, type Session, type Agent } from "../types/session" import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions" -import { instances, stopInstance } from "../stores/instances" +import { instances } from "../stores/instances" +import { closeInstanceTab } from "../stores/app-tabs" import { agents } from "../stores/sessions" import { getLogger } from "../lib/logger" import { useI18n } from "../lib/i18n" @@ -67,7 +68,7 @@ const SessionPicker: Component = (props) => { } async function handleCancel() { - stopInstance(props.instanceId) + closeInstanceTab(props.instanceId) props.onClose() } diff --git a/packages/ui/src/components/session/session-idle-attention.test.ts b/packages/ui/src/components/session/session-idle-attention.test.ts new file mode 100644 index 00000000..81c12b99 --- /dev/null +++ b/packages/ui/src/components/session/session-idle-attention.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { canMarkSessionIdleSeen } from "./session-idle-attention.ts" + +describe("idle marker attention", () => { + it("retains unseen idle while hidden or blurred and clears only when active, visible, and focused", () => { + assert.equal(canMarkSessionIdleSeen({ active: true, visibilityState: "hidden", focused: true }), false) + assert.equal(canMarkSessionIdleSeen({ active: true, visibilityState: "visible", focused: false }), false) + assert.equal(canMarkSessionIdleSeen({ active: false, visibilityState: "visible", focused: true }), false) + assert.equal(canMarkSessionIdleSeen({ active: true, visibilityState: "visible", focused: true }), true) + }) +}) diff --git a/packages/ui/src/components/session/session-idle-attention.ts b/packages/ui/src/components/session/session-idle-attention.ts new file mode 100644 index 00000000..808cda74 --- /dev/null +++ b/packages/ui/src/components/session/session-idle-attention.ts @@ -0,0 +1,7 @@ +export function canMarkSessionIdleSeen(input: { + active: boolean + visibilityState: DocumentVisibilityState + focused: boolean +}): boolean { + return input.active && input.visibilityState === "visible" && input.focused +} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 48cb3918..ee6a4292 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo, createEffect, createSignal, on, type Component } from "solid-js" +import { Show, createMemo, createEffect, createSignal, on, onCleanup, onMount, type Component } from "solid-js" import type { Session } from "../../types/session" import type { Attachment } from "../../types/attachment" import type { ClientPart } from "../../types/message" @@ -9,6 +9,7 @@ import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar" import { getAttachments, removeAttachment } from "../../stores/attachments" import { instances, waitForInstanceWorkspaceMetadataHydration } from "../../stores/instances" import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession } from "../../stores/sessions" +import { canMarkSessionIdleSeen } from "./session-idle-attention" import { clearSessionIdleFade, IDLE_STATUS_VISIBILITY_MS, getSessionStatus, isSessionBusy as getSessionBusyStatus, markSessionIdleFadeStarted } from "../../stores/session-status" import { showAlertDialog } from "../../stores/alerts" import { getLogger } from "../../lib/logger" @@ -47,6 +48,24 @@ interface SessionViewProps { } export const SessionView: Component = (props) => { + const [documentAttentionActive, setDocumentAttentionActive] = createSignal(false) + + onMount(() => { + const updateDocumentAttention = () => setDocumentAttentionActive(canMarkSessionIdleSeen({ + active: true, + visibilityState: document.visibilityState, + focused: document.hasFocus(), + })) + updateDocumentAttention() + window.addEventListener("focus", updateDocumentAttention) + window.addEventListener("blur", updateDocumentAttention) + document.addEventListener("visibilitychange", updateDocumentAttention) + onCleanup(() => { + window.removeEventListener("focus", updateDocumentAttention) + window.removeEventListener("blur", updateDocumentAttention) + document.removeEventListener("visibilitychange", updateDocumentAttention) + }) + }) const { t } = useI18n() const { preferences } = useConfig() const session = () => props.activeSessions.get(props.sessionId) @@ -216,7 +235,11 @@ export const SessionView: Component = (props) => { createEffect(() => { const currentSession = session() - if (!props.isActive || !currentSession) return + if (!currentSession || !canMarkSessionIdleSeen({ + active: props.isActive === true && documentAttentionActive(), + visibilityState: document.visibilityState, + focused: document.hasFocus(), + })) return const seenIdleEntries = getSeenIdleEntries(currentSession, preferences().keepUnseenSubagentIdleStatus) for (const entry of seenIdleEntries) { diff --git a/packages/ui/src/lib/client-identity.test.ts b/packages/ui/src/lib/client-identity.test.ts new file mode 100644 index 00000000..4d2b98ee --- /dev/null +++ b/packages/ui/src/lib/client-identity.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { getClientIdentity } from "./client-identity.ts" + +class MemoryStorage { + private readonly values = new Map() + getItem(key: string): string | null { return this.values.get(key) ?? null } + setItem(key: string, value: string): void { this.values.set(key, value) } +} + +describe("client identity", () => { + it("shares client id while native windows use distinct stable connection ids", () => { + const localStorage = new MemoryStorage() + const sessionStorage = new MemoryStorage() + const setWindow = (windowId: string) => { + ;(globalThis as any).window = { localStorage, sessionStorage, __CODENOMAD_WINDOW_ID__: windowId } + } + + setWindow("window-a") + const first = getClientIdentity() + setWindow("window-b") + const second = getClientIdentity() + setWindow("window-a") + const reloaded = getClientIdentity() + + assert.equal(first.clientId, second.clientId) + assert.equal(first.connectionId, "window-a") + assert.equal(second.connectionId, "window-b") + assert.equal(reloaded.connectionId, first.connectionId) + delete (globalThis as any).window + }) +}) diff --git a/packages/ui/src/lib/client-identity.ts b/packages/ui/src/lib/client-identity.ts index 75ed545e..f4d626f7 100644 --- a/packages/ui/src/lib/client-identity.ts +++ b/packages/ui/src/lib/client-identity.ts @@ -18,6 +18,8 @@ function getOrCreateClientId(): string { } function getOrCreateConnectionId(): string { + const nativeWindowId = window.__CODENOMAD_WINDOW_ID__?.trim() + if (nativeWindowId) return nativeWindowId if (cachedConnectionId) return cachedConnectionId cachedConnectionId = getOrCreateStoredValue(CONNECTION_ID_STORAGE_KEY, window.sessionStorage) return cachedConnectionId diff --git a/packages/ui/src/lib/hooks/use-app-lifecycle.ts b/packages/ui/src/lib/hooks/use-app-lifecycle.ts index 062963c3..262dd6bf 100644 --- a/packages/ui/src/lib/hooks/use-app-lifecycle.ts +++ b/packages/ui/src/lib/hooks/use-app-lifecycle.ts @@ -17,7 +17,6 @@ interface UseAppLifecycleOptions { setEscapeInDebounce: (value: boolean) => void handleNewInstanceRequest: () => void handleCloseActiveTab: () => Promise - handleCloseInstance: (instanceId: string) => Promise handleNewSession: (instanceId: string) => Promise handleCloseSession: (instanceId: string, sessionId: string) => Promise showFolderSelection: Accessor diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts index 53f0683b..090a5bee 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts @@ -19,3 +19,7 @@ it("keeps navigation flushes nonterminal", () => { assert.match(capture, /"client-state:flush-requested",[\s\S]*?, true\)/) assert.match(capture, /"client-state:navigation-flush-requested",[\s\S]*?, false\)/) }) + +it("resumes capture only after native shutdown cancellation", () => { + assert.match(capture, /listen\("client-state:flush-cancelled"[\s\S]*nativeShutdownStarted = false[\s\S]*schedule\(\)/) +}) diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.ts b/packages/ui/src/lib/hooks/use-app-session-capture.ts index df9bc82e..a6a3290e 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.ts @@ -193,6 +193,13 @@ export function useAppSessionCapture() { ({ generation }) => acknowledgeNativeClientStateRendererFlush(generation), true), register<{ generation: number }>("client-state:navigation-flush-requested", ({ generation }) => acknowledgeNativeClientStateNavigationFlush(generation), false), + listen("client-state:flush-cancelled", () => { + nativeShutdownStarted = false + schedule() + }).then((unlisten) => { + if (nativeDisposed) unlisten() + else nativeUnlisteners.push(unlisten) + }), ]).then(() => undefined) : Promise.resolve() const markScrollAuthority = (instanceId: string, sessionId: string) => { diff --git a/packages/ui/src/lib/hooks/use-app-session-restore.ts b/packages/ui/src/lib/hooks/use-app-session-restore.ts index 761e18f8..c5f56204 100644 --- a/packages/ui/src/lib/hooks/use-app-session-restore.ts +++ b/packages/ui/src/lib/hooks/use-app-session-restore.ts @@ -13,7 +13,7 @@ import { } from "../../stores/app-session-reconciliation" import { getAbortReason, runAbortable } from "../../stores/app-session-restore-timeout" import { - activeAppTabId, appTabOrderRevision, appTabSelectionRevision, getInstanceAppTabId, + activeAppTabId, appTabOrderRevision, appTabSelectionRevision, attachInstanceTab, getInstanceAppTabId, getSidecarAppTabId, selectAppTab, setAppTabOrder, } from "../../stores/app-tabs" import { @@ -94,6 +94,7 @@ async function restoreTabs(context: RestoreContext): Promise { const tab = snapshot.tabs[tabIndex] if (tab?.kind === "workspace") seedRestoredWorkspaceScrollSnapshots(existingWorkspaceId!, tab) capture.recordRestoredTab(tabIndex, getInstanceAppTabId(existingWorkspaceId!)) + attachInstanceTab(existingWorkspaceId!, { source: "restore" }) }) const claimedIds = new Set(existing.map(({ existingWorkspaceId }) => existingWorkspaceId!)) context.applyOrder() @@ -114,7 +115,7 @@ async function restoreTabs(context: RestoreContext): Promise { const instanceId = await runAbortable(async (operationSignal) => { const existingId = match.existingWorkspaceId const create = () => createInstance(tab.folder, tab.projectName, { - activate: false, signal: operationSignal, + signal: operationSignal, waitForCreateCommit: waitForCreateCommit ? () => waitForCreateCommit : undefined, shouldCreateCommit: canCommitCreation, onBeforeCreateCommit: (id) => seedRestoredWorkspaceScrollSnapshots(id, tab), @@ -126,6 +127,7 @@ async function restoreTabs(context: RestoreContext): Promise { const id = existingId ?? creation?.instanceId ?? null if (!id) return null claimedIds.add(id) + attachInstanceTab(id, { source: "restore" }) const created = creation?.reused === false if (created) createdId = id try { diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index d9059fe7..ad8d4131 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -50,7 +50,7 @@ export interface UseCommandsOptions { setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void handleNewInstanceRequest: () => void handleCloseActiveTab: () => Promise - handleCloseInstance: (instanceId: string) => Promise + handleStopInstance: (instanceId: string) => Promise handleNewSession: (instanceId: string) => Promise handleCloseSession: (instanceId: string, sessionId: string) => Promise getActiveInstance: () => Instance | null @@ -133,6 +133,19 @@ export function useCommands(options: UseCommandsOptions) { }, }) + commandRegistry.register({ + id: "stop-instance", + label: () => tGlobal("commands.stopInstance.label"), + description: () => tGlobal("commands.stopInstance.description"), + category: "Instance", + keywords: () => splitKeywords("commands.stopInstance.keywords"), + disabled: () => !activeInstance(), + action: async () => { + const instance = activeInstance() + if (instance) await options.handleStopInstance(instance.id) + }, + }) + if (canOpenWorkspacePaths()) { commandRegistry.register({ id: "open-workspace-folder", diff --git a/packages/ui/src/lib/hooks/use-electron-folder-launch.test.ts b/packages/ui/src/lib/hooks/use-electron-folder-launch.test.ts new file mode 100644 index 00000000..27551766 --- /dev/null +++ b/packages/ui/src/lib/hooks/use-electron-folder-launch.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { installElectronFolderLaunchHandler } from "./use-electron-folder-launch" + +const tick = () => new Promise((resolve) => setImmediate(resolve)) + +test("folder subscription is installed before one-at-a-time ordered delivery", async () => { + const calls: string[] = [] + let notify: (() => void) | undefined + const pending = ["/one", "/two"] + const cleanup = installElectronFolderLaunchHandler({ + onPendingFolders: (callback) => { calls.push("subscribe"); notify = callback; return () => calls.push("unsubscribe") }, + nextPendingFolder: async () => { calls.push("next"); return pending[0] ?? null }, + acknowledgePendingFolder: async (folder, opened) => { calls.push(`ack:${folder}:${opened}`); if (opened) pending.shift() }, + }, async (folder) => { calls.push(`open:${folder}`) }, (error) => assert.fail(String(error))) + await tick(); await tick() + cleanup() + assert.deepEqual(calls, ["subscribe", "next", "open:/one", "ack:/one:true", "next", "open:/two", "ack:/two:true", "next", "unsubscribe"]) +}) + +test("failed folder opens rotate behind later entries and remain bounded by the native queue", async () => { + const pending = ["/one", "/two"] + const opened: string[] = [] + const acknowledgements: string[] = [] + const errors: string[] = [] + let notify: (() => void) | undefined + let fail = true + const cleanup = installElectronFolderLaunchHandler({ + onPendingFolders: (callback) => { notify = callback; return () => {} }, + nextPendingFolder: async () => pending[0] ?? null, + acknowledgePendingFolder: async (folder, success) => { + acknowledgements.push(`${folder}:${success}`) + pending.shift() + if (!success) pending.push(folder) + }, + }, async (folder) => { + opened.push(folder) + if (fail) { fail = false; throw new Error("failed") } + }, (error) => errors.push(String(error))) + await tick(); await tick(); await tick() + cleanup() + assert.deepEqual(opened, ["/one", "/two", "/one"]) + assert.deepEqual(acknowledgements, ["/one:false", "/two:true", "/one:true"]) + assert.equal(errors.length, 1) +}) + +test("a false launch result is negatively acknowledged without hiding the UI error", async () => { + const pending = ["/failed", "/later"] + const acknowledgements: string[] = [] + const cleanup = installElectronFolderLaunchHandler({ + onPendingFolders: () => () => {}, + nextPendingFolder: async () => pending[0] ?? null, + acknowledgePendingFolder: async (folder, opened) => { + acknowledgements.push(`${folder}:${opened}`) + pending.shift() + if (!opened && folder === "/failed") pending.push(folder) + }, + }, async (folder) => folder !== "/failed" || acknowledgements.length > 0, (error) => assert.fail(String(error))) + await tick(); await tick(); await tick() + cleanup() + assert.deepEqual(acknowledgements, ["/failed:false", "/later:true", "/failed:true"]) +}) diff --git a/packages/ui/src/lib/hooks/use-electron-folder-launch.ts b/packages/ui/src/lib/hooks/use-electron-folder-launch.ts new file mode 100644 index 00000000..3af077fd --- /dev/null +++ b/packages/ui/src/lib/hooks/use-electron-folder-launch.ts @@ -0,0 +1,80 @@ +import { onCleanup, onMount } from "solid-js" +import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" +import { getLogger } from "../logger" +import { isElectronHost, isTauriHost } from "../runtime-env" + +export interface DesktopFolderLaunchAPI { + nextPendingFolder?: () => Promise + acknowledgePendingFolder?: (folder: string, opened: boolean) => Promise + onPendingFolders?: (callback: () => void) => () => void +} + +export function installElectronFolderLaunchHandler( + api: DesktopFolderLaunchAPI | undefined, + openFolder: (path: string) => boolean | void | Promise, + reportError: (error: unknown) => void, +): () => void { + if (!api?.nextPendingFolder || !api.acknowledgePendingFolder || !api.onPendingFolders) return () => {} + let disposed = false + let draining = false + let drainAgain = false + const drain = async () => { + if (draining) { drainAgain = true; return } + draining = true + try { + do { + drainAgain = false + while (!disposed) { + const folder = await api.nextPendingFolder!() + if (!folder) break + try { + const opened = (await openFolder(folder)) !== false + await api.acknowledgePendingFolder!(folder, opened) + } catch (error) { + await api.acknowledgePendingFolder!(folder, false).catch(() => {}) + reportError(error) + } + } + } while (drainAgain && !disposed) + } catch (error) { reportError(error) } finally { + draining = false + } + } + const unsubscribe = api.onPendingFolders(() => { void drain() }) + void drain() + return () => { disposed = true; unsubscribe() } +} + +export const installDesktopFolderLaunchHandler = installElectronFolderLaunchHandler + +export function useDesktopFolderLaunch(openFolder: (path: string) => boolean | void | Promise): void { + const log = getLogger("actions") + onMount(() => { + const reportError = (error: unknown) => log.error("Failed to open launched folder", error) + if (isElectronHost()) { + onCleanup(installDesktopFolderLaunchHandler(window.electronAPI, openFolder, reportError)) + return + } + if (!isTauriHost()) return + let disposed = false + let cleanup = () => {} + void (async () => { + let notify = () => {} + const unlisten = await listen("desktop:folders-pending", () => notify()) + if (disposed) { unlisten(); return } + cleanup = installDesktopFolderLaunchHandler({ + nextPendingFolder: () => invoke("desktop_launch_next_folder"), + acknowledgePendingFolder: (folder, opened) => invoke("desktop_launch_acknowledge_folder", { folder, opened }), + onPendingFolders: (callback) => { notify = callback; return () => { notify = () => {} } }, + }, openFolder, reportError) + await invoke("desktop_launch_ready") + if (disposed) { cleanup(); unlisten(); return } + const previous = cleanup + cleanup = () => { previous(); unlisten() } + })().catch(reportError) + onCleanup(() => { disposed = true; cleanup() }) + }) +} + +export const useElectronFolderLaunch = useDesktopFolderLaunch diff --git a/packages/ui/src/lib/i18n/messages/de/commands.ts b/packages/ui/src/lib/i18n/messages/de/commands.ts index 82e96514..f56ad923 100644 --- a/packages/ui/src/lib/i18n/messages/de/commands.ts +++ b/packages/ui/src/lib/i18n/messages/de/commands.ts @@ -31,6 +31,9 @@ export const commandMessages = { "commands.closeInstance.label": "Tab schließen", "commands.closeInstance.description": "Den aktuellen obersten Tab schließen", "commands.closeInstance.keywords": "stoppen, beenden, schließen, tab", + "commands.stopInstance.label": "Arbeitsbereich stoppen", + "commands.stopInstance.description": "Den gemeinsamen Arbeitsbereich für alle Fenster stoppen", + "commands.stopInstance.keywords": "stoppen, arbeitsbereich, server, gemeinsam", "commands.nextInstance.label": "Nächster Tab", "commands.nextInstance.description": "Zum nächsten obersten Tab wechseln", diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index cfabd78a..49fcceca 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -31,6 +31,9 @@ export const commandMessages = { "commands.closeInstance.label": "Close Tab", "commands.closeInstance.description": "Close the current top-level tab", "commands.closeInstance.keywords": "stop, quit, close, tab", + "commands.stopInstance.label": "Stop Workspace", + "commands.stopInstance.description": "Stop the shared workspace for every window", + "commands.stopInstance.keywords": "stop, workspace, server, shared", "commands.nextInstance.label": "Next Tab", "commands.nextInstance.description": "Cycle to the next top-level tab", diff --git a/packages/ui/src/lib/i18n/messages/es/commands.ts b/packages/ui/src/lib/i18n/messages/es/commands.ts index 6bd4dd7c..6f6feec8 100644 --- a/packages/ui/src/lib/i18n/messages/es/commands.ts +++ b/packages/ui/src/lib/i18n/messages/es/commands.ts @@ -31,6 +31,9 @@ export const commandMessages = { "commands.closeInstance.label": "Cerrar pestaña", "commands.closeInstance.description": "Cerrar la pestaña superior actual", "commands.closeInstance.keywords": "detener, salir, cerrar, pestaña", + "commands.stopInstance.label": "Detener espacio de trabajo", + "commands.stopInstance.description": "Detener el espacio de trabajo compartido para todas las ventanas", + "commands.stopInstance.keywords": "detener, espacio de trabajo, servidor, compartido", "commands.nextInstance.label": "Siguiente pestaña", "commands.nextInstance.description": "Cambiar a la siguiente pestaña superior", diff --git a/packages/ui/src/lib/i18n/messages/fr/commands.ts b/packages/ui/src/lib/i18n/messages/fr/commands.ts index 683b4221..27671157 100644 --- a/packages/ui/src/lib/i18n/messages/fr/commands.ts +++ b/packages/ui/src/lib/i18n/messages/fr/commands.ts @@ -31,6 +31,9 @@ export const commandMessages = { "commands.closeInstance.label": "Fermer l'onglet", "commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel", "commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet", + "commands.stopInstance.label": "Arrêter l'espace de travail", + "commands.stopInstance.description": "Arrêter l'espace de travail partagé pour toutes les fenêtres", + "commands.stopInstance.keywords": "arrêter, espace de travail, serveur, partagé", "commands.nextInstance.label": "Onglet suivant", "commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant", diff --git a/packages/ui/src/lib/i18n/messages/he/commands.ts b/packages/ui/src/lib/i18n/messages/he/commands.ts index 7cb77583..7e8bfdd5 100644 --- a/packages/ui/src/lib/i18n/messages/he/commands.ts +++ b/packages/ui/src/lib/i18n/messages/he/commands.ts @@ -31,6 +31,9 @@ export const commandMessages = { "commands.closeInstance.label": "סגור לשונית", "commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית", "commands.closeInstance.keywords": "עצור, סגור, לשונית", + "commands.stopInstance.label": "עצור סביבת עבודה", + "commands.stopInstance.description": "עצור את סביבת העבודה המשותפת בכל החלונות", + "commands.stopInstance.keywords": "עצור, סביבת עבודה, שרת, משותף", "commands.nextInstance.label": "הלשונית הבאה", "commands.nextInstance.description": "עבור ללשונית העליונה הבאה", diff --git a/packages/ui/src/lib/i18n/messages/ja/commands.ts b/packages/ui/src/lib/i18n/messages/ja/commands.ts index 5e2a5bce..162d5c8d 100644 --- a/packages/ui/src/lib/i18n/messages/ja/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ja/commands.ts @@ -31,6 +31,9 @@ export const commandMessages = { "commands.closeInstance.label": "タブを閉じる", "commands.closeInstance.description": "現在のトップレベルタブを閉じる", "commands.closeInstance.keywords": "閉じる, タブ, stop, quit, close", + "commands.stopInstance.label": "ワークスペースを停止", + "commands.stopInstance.description": "すべてのウィンドウで共有ワークスペースを停止する", + "commands.stopInstance.keywords": "停止, ワークスペース, サーバー, 共有", "commands.nextInstance.label": "次のタブ", "commands.nextInstance.description": "次のトップレベルタブへ切り替え", diff --git a/packages/ui/src/lib/i18n/messages/ne/commands.ts b/packages/ui/src/lib/i18n/messages/ne/commands.ts index 35bc74aa..d75c2bfc 100644 --- a/packages/ui/src/lib/i18n/messages/ne/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ne/commands.ts @@ -31,6 +31,9 @@ export const commandMessages = { "commands.closeInstance.label": "ट्याब बन्द गर्नुहोस्", "commands.closeInstance.description": "हालको शीर्ष-स्तरको ट्याब बन्द गर्नुहोस्", "commands.closeInstance.keywords": "रोक्नुहोस्, बन्द गर्नुहोस्, ट्याब", + "commands.stopInstance.label": "कार्यस्थान रोक्नुहोस्", + "commands.stopInstance.description": "सबै विन्डोका लागि साझा कार्यस्थान रोक्नुहोस्", + "commands.stopInstance.keywords": "रोक्नुहोस्, कार्यस्थान, सर्भर, साझा", "commands.nextInstance.label": "अर्को ट्याब", "commands.nextInstance.description": "अर्को शीर्ष-स्तरको ट्याबमा जानुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ru/commands.ts b/packages/ui/src/lib/i18n/messages/ru/commands.ts index 782b5a62..91198c71 100644 --- a/packages/ui/src/lib/i18n/messages/ru/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ru/commands.ts @@ -31,6 +31,9 @@ export const commandMessages = { "commands.closeInstance.label": "Закрыть вкладку", "commands.closeInstance.description": "Закрыть текущую верхнеуровневую вкладку", "commands.closeInstance.keywords": "остановить, выйти, закрыть, вкладка", + "commands.stopInstance.label": "Остановить рабочее пространство", + "commands.stopInstance.description": "Остановить общее рабочее пространство во всех окнах", + "commands.stopInstance.keywords": "остановить, рабочее пространство, сервер, общее", "commands.nextInstance.label": "Следующая вкладка", "commands.nextInstance.description": "Переключиться на следующую верхнеуровневую вкладку", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts index eabf6036..bd8917f7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts @@ -31,6 +31,9 @@ export const commandMessages = { "commands.closeInstance.label": "关闭标签页", "commands.closeInstance.description": "关闭当前顶层标签页", "commands.closeInstance.keywords": "stop, quit, close, 停止, 退出, 关闭, 标签", + "commands.stopInstance.label": "停止工作区", + "commands.stopInstance.description": "为所有窗口停止共享工作区", + "commands.stopInstance.keywords": "停止, 工作区, 服务器, 共享", "commands.nextInstance.label": "下一个标签页", "commands.nextInstance.description": "切换到下一个顶层标签页", diff --git a/packages/ui/src/lib/native/client-state.ts b/packages/ui/src/lib/native/client-state.ts index 836c2eb3..24c96b5c 100644 --- a/packages/ui/src/lib/native/client-state.ts +++ b/packages/ui/src/lib/native/client-state.ts @@ -6,6 +6,13 @@ export type NativeClientStateLoadResult = { isPrimary: boolean restoreEnabled: boolean snapshot: unknown | null + partitionProtocolVersion?: 1 +} +export type NativeClientStatePartitionCommit = { + protocolVersion: 1 + snapshot: unknown + partitions: Record + partitionKeys: string[] } const SECONDARY_CLIENT_STATE: NativeClientStateLoadResult = { isPrimary: false, restoreEnabled: false, snapshot: null } const log = getLogger("actions") @@ -34,7 +41,15 @@ async function claimNativeClientStateAccess(): Promise { export async function loadNativeClientState(): Promise { if (isElectronHost() || isTauriHost()) { if (!await claimNativeClientStateAccess()) return SECONDARY_CLIENT_STATE - return await dispatchNative((api) => api?.loadClientState?.(accessToken), "client_state_load") ?? SECONDARY_CLIENT_STATE + const loaded = await dispatchNative((api) => api?.loadClientState?.(accessToken), "client_state_load") ?? SECONDARY_CLIENT_STATE + if (!isElectronHost()) return loaded + const api = electronApi() + return { + ...loaded, + partitionProtocolVersion: loaded.partitionProtocolVersion === 1 + && typeof api?.commitClientStatePartitions === "function" + && typeof api.loadClientStatePartition === "function" ? 1 : undefined, + } } try { for (const key of LEGACY_WEB_KEYS) window.localStorage.removeItem(key) @@ -47,6 +62,12 @@ async function mutateNativeClientState(electronOperation: (api: ElectronAPI) => } export const saveNativeClientState = (snapshot: unknown): Promise => mutateNativeClientState((api) => api.saveClientState?.(accessToken, snapshot), "client_state_save", { snapshot }) +export const commitNativeClientStatePartitions = (payload: NativeClientStatePartitionCommit): Promise => + mutateNativeClientState((api) => api.commitClientStatePartitions?.(accessToken, payload), "client_state_commit_partitions", { payload }) +export const loadNativeClientStatePartition = async (key: string): Promise => { + if (!nativeAccessClaimed) return null + return await dispatchNative((api) => api?.loadClientStatePartition?.(accessToken, key), "client_state_load_partition", { key }) ?? null +} export const setNativeRestoreEnabled = (enabled: boolean): Promise => mutateNativeClientState((api) => api.setClientStateRestoreEnabled?.(accessToken, enabled), "client_state_set_restore_enabled", { enabled }) export const clearNativeClientState = (): Promise => diff --git a/packages/ui/src/stores/app-session-snapshot-merge.test.ts b/packages/ui/src/stores/app-session-snapshot-merge.test.ts index ceed916c..db8c6e0c 100644 --- a/packages/ui/src/stores/app-session-snapshot-merge.test.ts +++ b/packages/ui/src/stores/app-session-snapshot-merge.test.ts @@ -123,4 +123,22 @@ describe("app session snapshot merge", () => { }) assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 0) }) + + it("keeps an explicitly removed workspace removed through a later unavailable capture", () => { + const preservation = createRestorableSessionPreservation(session([ + workspace("/work", 0, { drafts: { session: "do not restore" } }), + ])) + recordRestoredTab(preservation, 0, "instance:work") + const binding = { runtimeTabId: "instance:work", folder: "/work", occurrence: 0 } + + markPreservedWorkspaceRemoved(preservation, binding) + markPreservedWorkspaceUnavailable( + preservation, + binding, + workspace("/work", 0, { drafts: { session: "captured after removal" } }), + ) + + assert.equal(preservation.results[0]?.status, "removed") + assert.deepEqual(mergeRestorableSessionState(empty(), preservation), empty()) + }) }) diff --git a/packages/ui/src/stores/app-session-snapshot-merge.ts b/packages/ui/src/stores/app-session-snapshot-merge.ts index b7d82b56..50fd8814 100644 --- a/packages/ui/src/stores/app-session-snapshot-merge.ts +++ b/packages/ui/src/stores/app-session-snapshot-merge.ts @@ -123,7 +123,7 @@ export function markPreservedWorkspaceRemoved( workspace: { runtimeTabId: string; folder: string; occurrence: number }, ): RestorableSessionPreservation { const index = findWorkspaceSourceIndex(preservation, workspace) - if (index !== undefined && preservation.results[index]?.status === "pending") { + if (index !== undefined && preservation.results[index]?.status !== "removed") { preservation.results[index] = { status: "removed" } preservation.removalRevisions[index] = (preservation.removalRevisions[index] ?? 0) + 1 } @@ -298,6 +298,7 @@ export function markPreservedWorkspaceUnavailable( ): RestorableSessionPreservation { const index = findWorkspaceSourceIndex(preservation, workspace) if (index === undefined) return preservation + if (preservation.results[index]?.status === "removed") return preservation const source = preservation.sourceTabs[index] if (current) preservation.sourceTabs[index] = source?.kind === "workspace" ? mergeWorkspaceState(current, source, authority) diff --git a/packages/ui/src/stores/app-tab-membership.ts b/packages/ui/src/stores/app-tab-membership.ts new file mode 100644 index 00000000..ebe1ae10 --- /dev/null +++ b/packages/ui/src/stores/app-tab-membership.ts @@ -0,0 +1,37 @@ +import { createSignal } from "solid-js" + +const [openInstanceTabIds, setOpenInstanceTabIds] = createSignal>(new Set()) +const closeRequestListeners = new Set<(instanceId: string) => void>() + +function attachInstanceTabMembership(instanceId: string): void { + setOpenInstanceTabIds((previous) => { + if (previous.has(instanceId)) return previous + return new Set(previous).add(instanceId) + }) +} + +function detachInstanceTabMembership(instanceId: string): void { + setOpenInstanceTabIds((previous) => { + if (!previous.has(instanceId)) return previous + const next = new Set(previous) + next.delete(instanceId) + return next + }) +} + +function onInstanceTabCloseRequested(listener: (instanceId: string) => void): () => void { + closeRequestListeners.add(listener) + return () => closeRequestListeners.delete(listener) +} + +function requestInstanceTabClose(instanceId: string): void { + for (const listener of closeRequestListeners) listener(instanceId) +} + +export { + attachInstanceTabMembership, + detachInstanceTabMembership, + onInstanceTabCloseRequested, + openInstanceTabIds, + requestInstanceTabClose, +} diff --git a/packages/ui/src/stores/app-tabs.test.ts b/packages/ui/src/stores/app-tabs.test.ts new file mode 100644 index 00000000..8331849c --- /dev/null +++ b/packages/ui/src/stores/app-tabs.test.ts @@ -0,0 +1,190 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { serverApi } from "../lib/api-client.ts" +import type { Instance } from "../types/instance.ts" +import { + activeAppTabId, + appTabs, + attachInstanceTab, + closeInstanceTab, + setAppTabOrder, + selectInstanceTab, +} from "./app-tabs.ts" +import { + createRestorableSessionPreservation, + markPreservedWorkspaceRemoved, + markPreservedWorkspaceReopened, + mergeRestorableSessionState, + recordRestoredTab, +} from "./app-session-snapshot-merge.ts" +import type { RestorableSessionState, RestorableWorkspaceTabState } from "./client-state-codec.ts" +import { onInstanceLifecycleAuthority } from "./instance-lifecycle-authority.ts" +import { addInstance, instances, removeInstance, stopInstance, updateInstance } from "./instances.ts" +import { serverEvents } from "../lib/server-events.ts" + +function instance(id: string): Instance { + return { + id, + folder: `/${id}`, + port: 0, + pid: 0, + proxyPath: `/workspaces/${id}`, + status: "starting", + client: null, + environmentVariables: {}, + } +} + +function workspace(folder: string): RestorableWorkspaceTabState { + return { + kind: "workspace", folder, occurrence: 0, drafts: {}, attachments: {}, scrollSnapshots: {}, + unseenIdleSince: {}, generationRecovery: {}, + } +} + +function captureTabs(): RestorableSessionState { + const tabs = appTabs().map((tab) => tab.kind === "instance" + ? workspace(tab.instance.folder) + : { kind: "sidecar" as const, sidecarId: tab.sidecarTab.sidecarId }) + return { tabs, activeTabIndex: appTabs().findIndex((tab) => tab.id === activeAppTabId()) } +} + +describe("renderer-local workspace tabs", () => { + it("keeps catalog additions and updates closed until explicitly attached", () => { + const first = instance("catalog-first") + const second = instance("catalog-second") + addInstance(first) + addInstance(second) + try { + updateInstance(first.id, { projectName: "Updated" }) + assert.deepEqual(appTabs().filter((tab) => tab.kind === "instance"), []) + + attachInstanceTab(first.id) + assert.deepEqual(appTabs().map((tab) => tab.id), [`instance:${first.id}`]) + + selectInstanceTab(second.id) + assert.deepEqual(new Set(appTabs().map((tab) => tab.id)), new Set([`instance:${first.id}`, `instance:${second.id}`])) + } finally { + removeInstance(first.id, { authoritative: false }) + removeInstance(second.id, { authoritative: false }) + } + }) + + it("closes locally without deleting and does not resurrect a reused id", () => { + const workspace = instance("local-close") + const originalDelete = serverApi.deleteWorkspace + let deletes = 0 + serverApi.deleteWorkspace = async () => { deletes += 1 } + addInstance(workspace) + try { + selectInstanceTab(workspace.id) + closeInstanceTab(workspace.id) + assert.equal(instances().has(workspace.id), true) + assert.equal(appTabs().some((tab) => tab.id === `instance:${workspace.id}`), false) + assert.equal(deletes, 0) + + removeInstance(workspace.id) + selectInstanceTab(workspace.id) + addInstance(instance(workspace.id)) + assert.equal(appTabs().some((tab) => tab.id === `instance:${workspace.id}`), false) + } finally { + serverApi.deleteWorkspace = originalDelete + removeInstance(workspace.id, { authoritative: false }) + } + }) + + it("restores only explicitly attached window membership", () => { + const first = instance("restore-first") + const second = instance("restore-second") + addInstance(first) + addInstance(second) + try { + attachInstanceTab(second.id) + assert.deepEqual(appTabs().filter((tab) => tab.kind === "instance").map((tab) => tab.instance.id), [second.id]) + + removeInstance(second.id) + assert.equal(appTabs().some((tab) => tab.id === `instance:${second.id}`), false) + } finally { + removeInstance(first.id, { authoritative: false }) + removeInstance(second.id, { authoritative: false }) + } + }) + + it("keeps a locally closed restored binding out of capture until explicit reopen", () => { + const restored = instance("restored-local-close") + const saved: RestorableSessionState = { tabs: [workspace(restored.folder)], activeTabIndex: 0 } + const preservation = createRestorableSessionPreservation(saved) + recordRestoredTab(preservation, 0, `instance:${restored.id}`, new Set()) + const stopListening = onInstanceLifecycleAuthority((event) => { + if (event.instanceId !== restored.id) return + const binding = { runtimeTabId: `instance:${event.instanceId}`, folder: event.folder, occurrence: event.occurrence } + if (event.type === "removed") markPreservedWorkspaceRemoved(preservation, binding) + if (event.type === "opened") markPreservedWorkspaceReopened(preservation, binding) + }) + addInstance(restored) + attachInstanceTab(restored.id, { source: "restore" }) + try { + closeInstanceTab(restored.id) + assert.equal(mergeRestorableSessionState(captureTabs(), preservation, { currentTabIds: [] }).tabs.length, 0) + + selectInstanceTab(restored.id) + assert.equal(mergeRestorableSessionState(captureTabs(), preservation, { + currentTabIds: [`instance:${restored.id}`], + }).tabs.length, 1) + } finally { + stopListening() + removeInstance(restored.id, { authoritative: false }) + } + }) + + it("selects the adjacent reordered local tab before an authoritative stop removes the catalog member", () => { + const closed = instance("stopped-closed-catalog") + const stopped = instance("stopped-active") + const left = instance("stopped-left") + const right = instance("stopped-right") + for (const candidate of [closed, stopped, left, right]) addInstance(candidate) + try { + for (const candidate of [left, stopped, right]) attachInstanceTab(candidate.id) + setAppTabOrder([`instance:${right.id}`, `instance:${stopped.id}`, `instance:${left.id}`]) + selectInstanceTab(stopped.id) + + const dispatch = serverEvents as unknown as { dispatch(event: { type: "workspace.stopped"; workspaceId: string; reason: "deleted" }): void } + dispatch.dispatch({ type: "workspace.stopped", workspaceId: stopped.id, reason: "deleted" }) + + assert.equal(activeAppTabId(), `instance:${right.id}`) + assert.deepEqual(appTabs().map((tab) => tab.id), [`instance:${right.id}`, `instance:${left.id}`]) + assert.equal(instances().has(closed.id), true) + } finally { + for (const candidate of [closed, stopped, left, right]) removeInstance(candidate.id, { authoritative: false }) + } + }) + + it("uses explicit stop as the delete path and retains state when delete fails", async () => { + const workspace = instance("failed-stop") + const originalDelete = serverApi.deleteWorkspace + let deletes = 0 + serverApi.deleteWorkspace = async () => { + deletes += 1 + throw new Error("stop failed") + } + addInstance(workspace) + selectInstanceTab(workspace.id) + try { + const first = stopInstance(workspace.id) + const second = stopInstance(workspace.id) + assert.equal(first, second) + await assert.rejects(first, /stop failed/) + assert.equal(deletes, 1) + assert.equal(instances().has(workspace.id), true) + assert.equal(appTabs().some((tab) => tab.id === `instance:${workspace.id}`), true) + + serverApi.deleteWorkspace = async () => { deletes += 1 } + await stopInstance(workspace.id) + assert.equal(deletes, 2) + } finally { + serverApi.deleteWorkspace = originalDelete + removeInstance(workspace.id, { authoritative: false }) + } + }) +}) diff --git a/packages/ui/src/stores/app-tabs.ts b/packages/ui/src/stores/app-tabs.ts index e1cfadf2..2a7363e3 100644 --- a/packages/ui/src/stores/app-tabs.ts +++ b/packages/ui/src/stores/app-tabs.ts @@ -3,6 +3,14 @@ import type { Instance } from "../types/instance" import { activeInstanceId, claimRestoreCreatedInstanceForUser, instances, setActiveInstanceId } from "./instances" import { activeSidecarToken, setActiveSidecarToken, sidecarTabs, type SideCarTabRecord } from "./sidecars" import { appSessionRestoreGateActive } from "./app-session-restore-gate" +import { + attachInstanceTabMembership, + detachInstanceTabMembership, + onInstanceTabCloseRequested, + openInstanceTabIds, +} from "./app-tab-membership" +import { normalizeWorkspacePath } from "./app-session-reconciliation" +import { publishInstanceLifecycleAuthority } from "./instance-lifecycle-authority" export interface InstanceAppTab { id: string @@ -58,11 +66,13 @@ function rememberTabOrder(tabId: string) { const appTabs = createMemo(() => { const currentTabs = [ - ...Array.from(instances().values()).map((instance) => ({ - id: getInstanceAppTabId(instance.id), - kind: "instance" as const, - instance, - })), + ...Array.from(instances().values()) + .filter((instance) => openInstanceTabIds().has(instance.id)) + .map((instance) => ({ + id: getInstanceAppTabId(instance.id), + kind: "instance" as const, + instance, + })), ...sidecarTabs().map((sidecarTab) => ({ id: getSidecarAppTabId(sidecarTab.token), kind: "sidecar" as const, @@ -88,10 +98,16 @@ function selectAppTab(tabId: string | null, options?: { source?: "restore" }) { if (options?.source !== "restore") setAppTabSelectionRevision((revision) => revision + 1) if (!tabId) { setActiveAppTabId(null) + setActiveInstanceId(null) setActiveSidecarToken(null) return } + if (options?.source === "restore" && tabId.startsWith("instance:")) { + const instanceId = tabId.slice("instance:".length) + if (instances().has(instanceId)) attachInstanceTabMembership(instanceId) + } + const tab = appTabs().find((entry) => entry.id === tabId) if (!tab) return @@ -110,9 +126,45 @@ function selectAppTab(tabId: string | null, options?: { source?: "restore" }) { } function selectInstanceTab(instanceId: string) { + if (!instances().has(instanceId)) return + attachInstanceTab(instanceId) selectAppTab(getInstanceAppTabId(instanceId)) } +function publishLocalInstanceTabLifecycle(type: "opened" | "removed", instanceId: string): void { + const instance = instances().get(instanceId) + if (!instance) return + const folder = normalizeWorkspacePath(instance.folder) + const occurrence = Array.from(instances().values()) + .filter((candidate) => normalizeWorkspacePath(candidate.folder) === folder) + .findIndex((candidate) => candidate.id === instanceId) + if (occurrence >= 0) publishInstanceLifecycleAuthority({ type, instanceId, folder: instance.folder, occurrence }) +} + +function attachInstanceTab(instanceId: string, options?: { source?: "restore" }): void { + if (!instances().has(instanceId) || openInstanceTabIds().has(instanceId)) return + attachInstanceTabMembership(instanceId) + if (options?.source !== "restore") publishLocalInstanceTabLifecycle("opened", instanceId) +} + +function closeInstanceTab(instanceId: string, options?: { forceFallbackSelection?: boolean }): void { + if (!openInstanceTabIds().has(instanceId)) return + const tabId = getInstanceAppTabId(instanceId) + const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId() + detachInstanceTabMembership(instanceId) + publishLocalInstanceTabLifecycle("removed", instanceId) + setTabOrder((previous) => previous.filter((id) => id !== tabId)) + setAppTabSelectionRevision((revision) => revision + 1) + setAppTabOrderRevision((revision) => revision + 1) + if (activeAppTabId() !== tabId) return + setActiveAppTabId(null) + setActiveInstanceId(null) + if (options?.forceFallbackSelection) selectAppTab(fallbackTabId) + else ensureActiveAppTab(fallbackTabId) +} + +onInstanceTabCloseRequested((instanceId) => closeInstanceTab(instanceId, { forceFallbackSelection: true })) + function selectSidecarTab(token: string) { selectAppTab(getSidecarAppTabId(token)) } @@ -206,6 +258,8 @@ export { appTabSelectionRevision, activeAppTab, appTabs, + attachInstanceTab, + closeInstanceTab, ensureActiveAppTab, getAdjacentAppTabId, getAppTabById, diff --git a/packages/ui/src/stores/client-state-codec.test.ts b/packages/ui/src/stores/client-state-codec.test.ts index f5d1012c..7925ab30 100644 --- a/packages/ui/src/stores/client-state-codec.test.ts +++ b/packages/ui/src/stores/client-state-codec.test.ts @@ -76,7 +76,8 @@ describe("client state codec", () => { ["non-record layout", snapshot({ layout: [] }), null], ] for (const [label, value, expected] of cases) assert.equal(decodeClientSnapshot(value), expected, label) - assert.equal(isFutureClientSnapshot({ version: 2 }), true, "future envelope") + assert.equal(isFutureClientSnapshot({ version: 3 }), true, "future envelope") + assert.equal(isFutureClientSnapshot({ version: 2 }), false, "partitioned envelope") assert.equal(isFutureClientSnapshot({ version: 1 }), false, "current envelope") assert.equal(normalizeRestorableSession({ tabs: [{ kind: "workspace" }], activeTabIndex: 0 }), null) assert.equal(normalizeWorkspace({}).expandedSessionIds, undefined) diff --git a/packages/ui/src/stores/client-state-codec.ts b/packages/ui/src/stores/client-state-codec.ts index 1ca55118..a89a5818 100644 --- a/packages/ui/src/stores/client-state-codec.ts +++ b/packages/ui/src/stores/client-state-codec.ts @@ -298,5 +298,5 @@ export function decodeClientSnapshot(value: unknown): ClientSnapshotV1 | null { } export function isFutureClientSnapshot(value: unknown): boolean { - return isRecord(value) && typeof value.version === "number" && Number.isInteger(value.version) && value.version > 1 + return isRecord(value) && typeof value.version === "number" && Number.isInteger(value.version) && value.version > 2 } diff --git a/packages/ui/src/stores/client-state-partitions.test.ts b/packages/ui/src/stores/client-state-partitions.test.ts new file mode 100644 index 00000000..e1f3e1e3 --- /dev/null +++ b/packages/ui/src/stores/client-state-partitions.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict" +import { it } from "node:test" + +import type { ClientSnapshotV1, RestorableWorkspaceTabState } from "./client-state-codec.ts" +import { canCommitClientSnapshotV2, canonicalJson, decodeClientSnapshotV2, encodeClientSnapshotV2, sha256 } from "./client-state-partitions.ts" + +const attachment = (id: string) => ({ + id, + type: "text" as const, + display: `@${id}`, + url: "", + filename: `${id}.txt`, + mediaType: "text/plain", + source: { type: "text" as const, value: id }, +}) +const workspace = (occurrence: number, draft: string): RestorableWorkspaceTabState => ({ + kind: "workspace", + folder: "/same-folder", + occurrence, + projectName: "same-project", + activeParentSessionId: "parent", + activeSessionId: "session-1", + expandedSessionIds: ["parent", "session-1"], + drafts: { + "session-1": draft, + ...(occurrence === 1 ? { __no_session_draft__: "new session prompt" } : {}), + }, + attachments: { "session-1": [attachment(`attachment-${occurrence}`)] }, + scrollSnapshots: { + "session-1": { scrollTop: occurrence * 100, atBottom: occurrence === 0, updatedAt: occurrence + 10 }, + }, + unseenIdleSince: { "session-1": occurrence + 20 }, + generationRecovery: { "session-1": occurrence === 0 ? "working" : "interrupted" }, +}) +const graphSnapshot = (): ClientSnapshotV1 => ({ + version: 1, + revision: 1, + savedAt: 2, + layout: { panel: "320" }, + session: { tabs: [workspace(0, "first draft"), workspace(1, "second draft")], activeTabIndex: 0 }, +}) +const sidecarSnapshot: ClientSnapshotV1 = { + version: 1, + revision: 1, + savedAt: 2, + layout: { panel: "320" }, + session: { tabs: [{ kind: "sidecar", sidecarId: "docs" }], activeTabIndex: 0 }, +} +const loader = (encoded: Awaited>) => + async (key: string) => encoded.partitions[key] ?? null + +it("canonicalizes recursively without reordering arrays and hashes the exact UTF-8 text", async () => { + const value = { z: [{ y: 2, x: 1 }, "first"], a: { d: 4, c: 3 } } + const reordered = { a: { c: 3, d: 4 }, z: [{ x: 1, y: 2 }, "first"] } + const canonical = '{"a":{"c":3,"d":4},"z":[{"x":1,"y":2},"first"]}' + + assert.equal(canonicalJson(value), canonical) + assert.equal(canonicalJson(reordered), canonical) + assert.notEqual(canonicalJson({ values: [1, 2] }), canonicalJson({ values: [2, 1] })) + assert.equal(await sha256(canonical), "23ed87995559a7cb383a758e7e2728ae0da9d8796565f005b92a4a7b2ba67d00") +}) + +it("round trips the complete graph without matching duplicate workspaces or session IDs", async () => { + const snapshot = graphSnapshot() + const encoded = await encodeClientSnapshotV2(snapshot) + const manifest = JSON.parse(encoded.partitions[encoded.root.sessionPartition]!) + const firstShell = manifest.session.tabs[0] + const secondShell = manifest.session.tabs[1] + + assert.notEqual(firstShell.workspacePartition, secondShell.workspacePartition) + assert.equal(firstShell.folder, secondShell.folder) + assert.equal(Object.prototype.hasOwnProperty.call(firstShell, "drafts"), false) + assert.equal(Object.prototype.hasOwnProperty.call(secondShell, "drafts"), false) + assert.equal( + canonicalJson(await decodeClientSnapshotV2(encoded.root, 1, loader(encoded))), + canonicalJson(snapshot), + ) +}) + +it("produces stable deduplicated hashes and complete sorted partition keys", async () => { + const tab = workspace(0, "same draft") + const snapshot: ClientSnapshotV1 = { + ...graphSnapshot(), + session: { tabs: [tab, structuredClone(tab)], activeTabIndex: 0 }, + } + const first = await encodeClientSnapshotV2(snapshot) + const second = await encodeClientSnapshotV2(snapshot) + const manifest = JSON.parse(first.partitions[first.root.sessionPartition]!) + + assert.deepEqual(first, second) + assert.equal(manifest.session.tabs.length, 2) + assert.equal(manifest.session.tabs[0].workspacePartition, manifest.session.tabs[1].workspacePartition) + assert.deepEqual(first.partitionKeys, [...new Set(first.partitionKeys)].sort()) + assert.deepEqual(first.root.partitionKeys, first.partitionKeys) + assert.deepEqual(Object.keys(first.partitions), first.partitionKeys) + assert.ok(first.partitionKeys.includes(first.root.sessionPartition)) +}) + +it("uses the native graph cap without truncating the encoded key list", async () => { + const encoded = await encodeClientSnapshotV2(sidecarSnapshot) + assert.equal(canCommitClientSnapshotV2({ ...encoded, partitionKeys: Array(4096).fill("key") }), true) + assert.equal(canCommitClientSnapshotV2({ ...encoded, partitionKeys: Array(4097).fill("key") }), false) +}) + +it("rejects a missing or corrupt inactive session document", async () => { + const encoded = await encodeClientSnapshotV2(graphSnapshot()) + const manifest = JSON.parse(encoded.partitions[encoded.root.sessionPartition]!) + const inactiveWorkspace = JSON.parse(encoded.partitions[manifest.session.tabs[1].workspacePartition]!) + const inactiveDocument = inactiveWorkspace.sessions["session-1"] + + for (const failure of ["missing", "corrupt"] as const) { + const load = async (key: string) => key === inactiveDocument + ? failure === "missing" ? null : `${encoded.partitions[key]} ` + : encoded.partitions[key] ?? null + assert.equal(await decodeClientSnapshotV2(encoded.root, 1, load), null, failure) + } +}) + +it("rejects missing, reordered, and disconnected root graph keys", async () => { + const encoded = await encodeClientSnapshotV2(graphSnapshot()) + const removable = encoded.root.partitionKeys.find((key) => key !== encoded.root.sessionPartition)! + const withoutReachable = encoded.root.partitionKeys.filter((key) => key !== removable) + const extraContent = canonicalJson({ disconnected: true }) + const extraKey = await sha256(extraContent) + const withExtra = [...encoded.root.partitionKeys, extraKey].sort() + const partitions = { ...encoded.partitions, [extraKey]: extraContent } + + assert.equal(await decodeClientSnapshotV2( + { ...encoded.root, partitionKeys: withoutReachable }, 1, loader(encoded), + ), null) + assert.equal(await decodeClientSnapshotV2( + { ...encoded.root, partitionKeys: [...encoded.root.partitionKeys].reverse() }, 1, loader(encoded), + ), null) + assert.equal(await decodeClientSnapshotV2( + { ...encoded.root, partitionKeys: withExtra }, 1, async (key) => partitions[key] ?? null, + ), null) +}) + +it("rejects root and legacy session data changed by normalization", async () => { + const partition = canonicalJson({ + ...sidecarSnapshot.session, + tabs: [{ ...sidecarSnapshot.session!.tabs[0], dropped: true }], + }) + const partitionKey = await sha256(partition) + const encoded = await encodeClientSnapshotV2(sidecarSnapshot) + + assert.equal(await decodeClientSnapshotV2( + { ...encoded.root, layout: { ...encoded.root.layout, dropped: 42 } }, + 1, + loader(encoded), + ), null) + assert.equal(await decodeClientSnapshotV2( + { ...encoded.root, sessionPartition: partitionKey }, + 1, + async () => partition, + ), null) +}) + +it("rejects unsafe persisted workspace session IDs", async () => { + for (const unsafe of ["__proto__", "constructor", "prototype", "x".repeat(513)]) { + for (const field of ["records", "activeParentSessionId", "activeSessionId", "expandedSessionIds"] as const) { + const tab = workspace(0, "draft") + if (field === "records") tab.drafts = Object.assign(Object.create(null), { [unsafe]: "draft" }) + else if (field === "expandedSessionIds") tab.expandedSessionIds = [unsafe] + else tab[field] = unsafe + const encoded = await encodeClientSnapshotV2({ + ...graphSnapshot(), session: { tabs: [tab], activeTabIndex: 0 }, + }) + assert.equal(await decodeClientSnapshotV2(encoded.root, 1, loader(encoded)), null, `${field}: ${unsafe}`) + } + } +}) + +it("rejects noncanonical graph documents with duplicate record keys", async () => { + const partition = '{"format":2,"format":2,"session":null}' + const partitionKey = await sha256(partition) + const encoded = await encodeClientSnapshotV2({ ...sidecarSnapshot, session: null }) + + assert.equal(await decodeClientSnapshotV2( + { ...encoded.root, sessionPartition: partitionKey }, + 1, + async () => partition, + ), null) +}) diff --git a/packages/ui/src/stores/client-state-partitions.ts b/packages/ui/src/stores/client-state-partitions.ts new file mode 100644 index 00000000..8ca64413 --- /dev/null +++ b/packages/ui/src/stores/client-state-partitions.ts @@ -0,0 +1,321 @@ +import { decodeClientSnapshot, normalizeRestorableSession } from "./client-state-codec" +import type { ClientSnapshotV1, RestorableSessionState, RestorableWorkspaceTabState } from "./client-state-codec" + +const MAX_PARTITION_BYTES = 1024 * 1024 +const MAX_ROOT_BYTES = 1024 * 1024 +const MAX_NATIVE_PARTITIONS = 4096 +const MAX_SESSION_ID = 512 +const PARTITION_KEY = /^[0-9a-f]{64}$/ +const ROOT_KEYS = ["layout", "partitionKeys", "revision", "savedAt", "sessionPartition", "version"] +const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]) +const encoder = new TextEncoder() + +export interface ClientSnapshotV2 { + version: 2 + revision: number + savedAt: number + layout: Record + sessionPartition: string + partitionKeys: string[] +} + +export interface EncodedClientSnapshotV2 { + root: ClientSnapshotV2 + partitions: Record + partitionKeys: string[] +} + +interface SessionDocument { + format: 1 + draft?: string + attachments?: RestorableWorkspaceTabState["attachments"][string] + scrollSnapshot?: RestorableWorkspaceTabState["scrollSnapshots"][string] + unseenIdleSince?: number + generationRecovery?: RestorableWorkspaceTabState["generationRecovery"][string] +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) +const hasOwn = (value: object, key: string) => Object.prototype.hasOwnProperty.call(value, key) +const isSafePersistedSessionId = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= MAX_SESSION_ID + && value.trim().length > 0 && !UNSAFE_KEYS.has(value) + +function isPartitionKeyArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((key, index) => + typeof key === "string" && PARTITION_KEY.test(key) && (index === 0 || value[index - 1]! < key)) +} + +function hasExactKeys(value: Record, required: readonly string[], optional: readonly string[] = []): boolean { + const allowed = new Set([...required, ...optional]) + return required.every((key) => hasOwn(value, key)) && Object.keys(value).every((key) => allowed.has(key)) +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson) + if (!isRecord(value)) return value + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])) +} + +export function canonicalJson(value: unknown): string { + const serialized = JSON.stringify(sortJson(value)) + if (serialized === undefined) throw new TypeError("Client state partition must be JSON-serializable") + return serialized +} + +function canonicalEquals(left: unknown, right: unknown): boolean { + try { + return canonicalJson(left) === canonicalJson(right) + } catch { + return false + } +} + +export async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} + +async function encodeSessionGraph(session: RestorableSessionState | null) { + const generated: Record = Object.create(null) + const addPartition = async (value: unknown) => { + const partition = canonicalJson(value) + const key = await sha256(partition) + generated[key] = partition + return key + } + + const encodedSession = session === null ? null : { + tabs: await Promise.all(session.tabs.map(async (tab) => { + if (tab.kind === "sidecar") return { kind: tab.kind, sidecarId: tab.sidecarId } + + const sessionIds = new Set([ + ...Object.keys(tab.drafts), + ...Object.keys(tab.attachments), + ...Object.keys(tab.scrollSnapshots), + ...Object.keys(tab.unseenIdleSince), + ...Object.keys(tab.generationRecovery), + ]) + const sessions: Record = Object.create(null) + for (const sessionId of [...sessionIds].sort()) { + const document: SessionDocument = { format: 1 } + if (hasOwn(tab.drafts, sessionId)) document.draft = tab.drafts[sessionId] + if (hasOwn(tab.attachments, sessionId)) document.attachments = tab.attachments[sessionId] + if (hasOwn(tab.scrollSnapshots, sessionId)) document.scrollSnapshot = tab.scrollSnapshots[sessionId] + if (hasOwn(tab.unseenIdleSince, sessionId)) document.unseenIdleSince = tab.unseenIdleSince[sessionId] + if (hasOwn(tab.generationRecovery, sessionId)) document.generationRecovery = tab.generationRecovery[sessionId] + sessions[sessionId] = await addPartition(document) + } + + const workspace: Record = { format: 1, sessions } + if (tab.activeParentSessionId !== undefined) workspace.activeParentSessionId = tab.activeParentSessionId + if (tab.activeSessionId !== undefined) workspace.activeSessionId = tab.activeSessionId + if (tab.expandedSessionIds !== undefined) workspace.expandedSessionIds = tab.expandedSessionIds + const shell: Record = { + kind: tab.kind, + folder: tab.folder, + workspacePartition: await addPartition(workspace), + } + if (tab.occurrence !== undefined) shell.occurrence = tab.occurrence + if (tab.projectName !== undefined) shell.projectName = tab.projectName + if (tab.binaryPath !== undefined) shell.binaryPath = tab.binaryPath + return shell + })), + activeTabIndex: session.activeTabIndex, + ...(session.homeActive !== undefined ? { homeActive: session.homeActive } : {}), + } + const sessionPartition = await addPartition({ format: 2, session: encodedSession }) + const partitionKeys = Object.keys(generated).sort() + return { + sessionPartition, + partitionKeys, + partitions: Object.fromEntries(partitionKeys.map((key) => [key, generated[key]!])), + } +} + +export async function encodeClientSnapshotV2(snapshot: ClientSnapshotV1): Promise { + const graph = await encodeSessionGraph(snapshot.session) + return { + root: { + version: 2, + revision: snapshot.revision, + savedAt: snapshot.savedAt, + layout: snapshot.layout, + sessionPartition: graph.sessionPartition, + partitionKeys: graph.partitionKeys, + }, + partitions: graph.partitions, + partitionKeys: graph.partitionKeys, + } +} + +export const canCommitClientSnapshotV2 = (encoded: EncodedClientSnapshotV2): boolean => + encoded.partitionKeys.length <= MAX_NATIVE_PARTITIONS + +function decodeRoot(value: unknown): { root: ClientSnapshotV1; partitionKey: string; partitionKeys: string[] } | null { + if (!isRecord(value) || value.version !== 2 || typeof value.sessionPartition !== "string" + || !PARTITION_KEY.test(value.sessionPartition) + || !isPartitionKeyArray(value.partitionKeys) + || !value.partitionKeys.includes(value.sessionPartition) + || Object.keys(value).sort().join("\0") !== ROOT_KEYS.join("\0")) return null + let serialized: string + try { + serialized = JSON.stringify(value) + } catch { + return null + } + if (encoder.encode(serialized).byteLength > MAX_ROOT_BYTES) return null + const root = decodeClientSnapshot({ ...value, version: 1, session: null }) + if (!root || !canonicalEquals(value, { + version: 2, + revision: root.revision, + savedAt: root.savedAt, + layout: root.layout, + sessionPartition: value.sessionPartition, + partitionKeys: value.partitionKeys, + })) return null + return { root, partitionKey: value.sessionPartition, partitionKeys: value.partitionKeys } +} + +function parseJson(value: string): { value: unknown } | null { + try { + return { value: JSON.parse(value) as unknown } + } catch { + return null + } +} + +async function decodeGraph( + manifest: Record, + rootKey: string, + persistedKeys: readonly string[], + loaded: Map, + loadPartition: (key: string) => Promise, +): Promise { + if (!hasExactKeys(manifest, ["format", "session"]) || canonicalJson(manifest) !== loaded.get(rootKey)) return + if (manifest.session === null) { + const reencoded = await encodeSessionGraph(null) + return graphMatches(reencoded, rootKey, persistedKeys, loaded) ? null : undefined + } + if (!isRecord(manifest.session) + || !hasExactKeys(manifest.session, ["tabs", "activeTabIndex"], ["homeActive"]) + || !Array.isArray(manifest.session.tabs)) return + + const loadCanonical = async (key: unknown): Promise | null> => { + if (typeof key !== "string" || !PARTITION_KEY.test(key)) return null + let partition = loaded.get(key) + if (partition === undefined) { + partition = await loadPartition(key) ?? undefined + if (partition === undefined || encoder.encode(partition).byteLength > MAX_PARTITION_BYTES + || await sha256(partition) !== key) return null + loaded.set(key, partition) + } + const parsed = parseJson(partition) + return parsed && isRecord(parsed.value) && canonicalJson(parsed.value) === partition ? parsed.value : null + } + + const tabs: unknown[] = [] + for (const shell of manifest.session.tabs) { + if (!isRecord(shell)) return + if (shell.kind === "sidecar") { + if (!hasExactKeys(shell, ["kind", "sidecarId"])) return + tabs.push({ kind: shell.kind, sidecarId: shell.sidecarId }) + continue + } + if (shell.kind !== "workspace" + || !hasExactKeys(shell, ["kind", "folder", "workspacePartition"], ["occurrence", "projectName", "binaryPath"])) return + const workspace = await loadCanonical(shell.workspacePartition) + if (!workspace || workspace.format !== 1 + || !hasExactKeys(workspace, ["format", "sessions"], + ["activeParentSessionId", "activeSessionId", "expandedSessionIds"]) + || !isRecord(workspace.sessions)) return + if (!Object.keys(workspace.sessions).every(isSafePersistedSessionId) + || (hasOwn(workspace, "activeParentSessionId") && !isSafePersistedSessionId(workspace.activeParentSessionId)) + || (hasOwn(workspace, "activeSessionId") && !isSafePersistedSessionId(workspace.activeSessionId)) + || (hasOwn(workspace, "expandedSessionIds") && (!Array.isArray(workspace.expandedSessionIds) + || !workspace.expandedSessionIds.every(isSafePersistedSessionId)))) return + + const tab: Record = { + kind: shell.kind, + folder: shell.folder, + drafts: Object.create(null), + attachments: Object.create(null), + scrollSnapshots: Object.create(null), + unseenIdleSince: Object.create(null), + generationRecovery: Object.create(null), + } + for (const key of ["occurrence", "projectName", "binaryPath"] as const) { + if (hasOwn(shell, key)) tab[key] = shell[key] + } + for (const key of ["activeParentSessionId", "activeSessionId", "expandedSessionIds"] as const) { + if (hasOwn(workspace, key)) tab[key] = workspace[key] + } + for (const [sessionId, documentKey] of Object.entries(workspace.sessions)) { + const document = await loadCanonical(documentKey) + if (!document || document.format !== 1 + || !hasExactKeys(document, ["format"], + ["draft", "attachments", "scrollSnapshot", "unseenIdleSince", "generationRecovery"]) + || Object.keys(document).length === 1) return + const fields = [ + ["draft", "drafts"], + ["attachments", "attachments"], + ["scrollSnapshot", "scrollSnapshots"], + ["unseenIdleSince", "unseenIdleSince"], + ["generationRecovery", "generationRecovery"], + ] as const + for (const [documentField, stateField] of fields) { + if (hasOwn(document, documentField)) (tab[stateField] as Record)[sessionId] = document[documentField] + } + } + tabs.push(tab) + } + + const reconstructed: unknown = { + tabs, + activeTabIndex: manifest.session.activeTabIndex, + ...(hasOwn(manifest.session, "homeActive") ? { homeActive: manifest.session.homeActive } : {}), + } + const normalized = normalizeRestorableSession(reconstructed) + if (!normalized || !canonicalEquals(reconstructed, normalized)) return + const reencoded = await encodeSessionGraph(normalized) + return graphMatches(reencoded, rootKey, persistedKeys, loaded) ? normalized : undefined +} + +function graphMatches( + reencoded: Awaited>, + rootKey: string, + persistedKeys: readonly string[], + loaded: Map, +): boolean { + if (reencoded.sessionPartition !== rootKey + || reencoded.partitionKeys.length !== persistedKeys.length + || !reencoded.partitionKeys.every((key, index) => key === persistedKeys[index]) + || reencoded.partitionKeys.length !== loaded.size) return false + return reencoded.partitionKeys.every((key) => loaded.get(key) === reencoded.partitions[key]) +} + +export async function decodeClientSnapshotV2( + value: unknown, + partitionProtocolVersion: 1 | undefined, + loadPartition: (key: string) => Promise, +): Promise { + const decoded = decodeRoot(value) + if (!decoded || partitionProtocolVersion !== 1) return null + try { + const partition = await loadPartition(decoded.partitionKey) + if (partition === null || encoder.encode(partition).byteLength > MAX_PARTITION_BYTES + || await sha256(partition) !== decoded.partitionKey) return null + const parsed = parseJson(partition) + if (!parsed) return null + if (isRecord(parsed.value) && parsed.value.format === 2) { + const loaded = new Map([[decoded.partitionKey, partition]]) + const session = await decodeGraph(parsed.value, decoded.partitionKey, decoded.partitionKeys, loaded, loadPartition) + return session === undefined ? null : { ...decoded.root, session } + } + return null + } catch { + return null + } +} + +export const isClientSnapshotV2 = (value: unknown): boolean => isRecord(value) && value.version === 2 diff --git a/packages/ui/src/stores/client-state.test.ts b/packages/ui/src/stores/client-state.test.ts index 882a9234..2b8084a6 100644 --- a/packages/ui/src/stores/client-state.test.ts +++ b/packages/ui/src/stores/client-state.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" +import { encodeClientSnapshotV2 } from "./client-state-partitions.ts" type ClientState = typeof import("./client-state.ts") type NativeApi = Record any>; type TransactionKind = "clear" | "disable" const layoutKey = "opencode-session-sidebar-width-v8"; let moduleId = 0 @@ -12,8 +13,9 @@ class MemoryStorage { setItem(key: string, value: string) { this.values.set(key, String(value)) } } const session = (sidecarId: string) => ({ tabs: [{ kind: "sidecar" as const, sidecarId }], activeTabIndex: 0 }) -const snapshot = (sidecarId: string, layout: Record = {}) => ({ version: 1, revision: 1, savedAt: 1, layout, session: session(sidecarId) }) -const loadResult = (saved: unknown = null, isPrimary = true) => ({ isPrimary, restoreEnabled: true, snapshot: saved }) +const snapshot = (sidecarId: string, layout: Record = {}) => ({ version: 1 as const, revision: 1, savedAt: 1, layout, session: session(sidecarId) }) +const loadResult = (saved: unknown = null, isPrimary = true, partitionProtocolVersion?: 1) => + ({ isPrimary, restoreEnabled: true, snapshot: saved, partitionProtocolVersion }) const deferred = () => { let resolve!: (value: T) => void, reject!: (error: Error) => void const promise = new Promise((yes, no) => { resolve = yes; reject = no }) @@ -117,6 +119,20 @@ describe("failed destructive transactions", () => { } }) describe("future envelopes and clear races", () => { + it("fences every malformed non-null snapshot", async () => { + for (const malformed of [{ version: 2.5 }, { version: "2" }, {}, [], 7, "snapshot", true]) { + let saves = 0 + const state = await boot({ + loadClientState: async () => loadResult(malformed), + saveClientState: async () => { saves += 1; return true }, + }) + state.writeClientLayoutValue(layoutKey, "350") + state.updateRestorableSession(session("overwrite")) + await state.flushClientState() + assert.equal(saves, 0, JSON.stringify(malformed)) + } + }) + it("serializes overlapping clear and disable transactions without stranding writes", async () => { const clear = deferred(), disable = deferred() const clearStarted = deferred(), disableStarted = deferred() @@ -151,7 +167,7 @@ describe("future envelopes and clear races", () => { it("clears a future envelope while suppressing recapture for the run", async () => { let clears = 0, saves = 0 const state = await boot({ - loadClientState: async () => loadResult({ version: 2, future: true }), + loadClientState: async () => loadResult({ version: 3, future: true }), saveClientState: async () => { saves += 1; return true }, clearClientState: async () => { clears += 1; return true }, }) @@ -165,7 +181,7 @@ describe("future envelopes and clear races", () => { it("keeps ownership of a future envelope after disable is rejected", async () => { let clears = 0 const state = await boot({ - loadClientState: async () => loadResult({ version: 2, future: true }), + loadClientState: async () => loadResult({ version: 3, future: true }), setClientStateRestoreEnabled: async () => false, clearClientState: async () => { clears += 1; return true }, }) @@ -248,3 +264,138 @@ describe("secondary hosts", () => { }) } }) + +describe("partitioned client state", () => { + it("commits one atomic graph without a monolithic save or automatic load rewrite", async () => { + const encoded = await encodeClientSnapshotV2(snapshot("restored", { [layoutKey]: "380" })) + const commits: any[] = []; let monolithicSaves = 0 + const state = await boot({ + loadClientState: async () => loadResult(encoded.root, true, 1), + loadClientStatePartition: async (_token, key) => encoded.partitions[key] ?? null, + commitClientStatePartitions: async (_token, value) => { commits.push(value); return true }, + saveClientState: async () => { monolithicSaves += 1; return true }, + }) + assert.equal(state.loadedRestorableSession()?.tabs[0]?.kind, "sidecar") + assert.equal(state.readClientLayoutValue(layoutKey), "380") + assert.equal(commits.length, 0, "load does not automatically rewrite the graph") + assert.equal(monolithicSaves, 0) + + state.updateRestorableSession(session("next")); await state.flushClientState() + assert.equal(monolithicSaves, 0) + assert.equal(commits.length, 1) + assert.equal(commits[0].snapshot.version, 2) + assert.equal(Object.prototype.hasOwnProperty.call(commits[0].snapshot, "session"), false) + assert.deepEqual(commits[0].partitionKeys, [...commits[0].partitionKeys].sort()) + assert.deepEqual(commits[0].snapshot.partitionKeys, commits[0].partitionKeys) + assert.deepEqual(Object.keys(commits[0].partitions), commits[0].partitionKeys) + const manifest = JSON.parse(commits[0].partitions[commits[0].snapshot.sessionPartition]) + assert.equal(manifest.format, 2) + assert.equal(manifest.session.tabs[0].sidecarId, "next") + }) + + it("uses the V1 monolithic save when partition capability is absent", async () => { + const saved: any[] = []; let commits = 0 + const state = await boot({ + loadClientState: async () => loadResult(snapshot("old")), + saveClientState: async (_token, value) => { saved.push(value); return true }, + commitClientStatePartitions: async () => { commits += 1; return true }, + }) + await state.flushClientState() + assert.equal(saved.length, 0, "loading V1 alone does not rewrite it") + state.updateRestorableSession(session("fallback")); await state.flushClientState() + assert.equal(commits, 0) + assert.equal(saved[0].version, 1) + assert.equal(saved[0].session.tabs[0].sidecarId, "fallback") + }) + + it("migrates a loaded V1 snapshot on the next real partition-capable save", async () => { + const commits: any[] = []; let saves = 0 + const state = await boot({ + loadClientState: async () => loadResult(snapshot("old"), true, 1), + loadClientStatePartition: async () => null, + commitClientStatePartitions: async (_token, value) => { commits.push(value); return true }, + saveClientState: async () => { saves += 1; return true }, + }) + await state.flushClientState() + assert.equal(commits.length, 0, "load does not automatically migrate") + state.updateRestorableSession(session("migrated")); await state.flushClientState() + assert.equal(saves, 0) + assert.equal(commits.length, 1) + assert.equal(commits[0].snapshot.version, 2) + const manifest = JSON.parse(commits[0].partitions[commits[0].snapshot.sessionPartition]) + assert.equal(manifest.session.tabs[0].sidecarId, "migrated") + }) + + for (const missing of ["commitClientStatePartitions", "loadClientStatePartition"] as const) { + it(`falls back to V1 when Electron advertises a stale capability without ${missing}`, async () => { + const saved: any[] = [] + const api: NativeApi = { + loadClientState: async () => loadResult(snapshot("old"), true, 1), + saveClientState: async (_token, value) => { saved.push(value); return true }, + commitClientStatePartitions: async () => true, + loadClientStatePartition: async () => null, + } + delete api[missing] + const state = await boot(api) + state.updateRestorableSession(session("fallback")); await state.flushClientState() + assert.equal(saved.length, 1) + assert.equal(saved[0].version, 1) + }) + } + + it("keeps a V2 root fenced when Electron advertises a stale partial capability", async () => { + const encoded = await encodeClientSnapshotV2(snapshot("stored")); let writes = 0 + const state = await boot({ + loadClientState: async () => loadResult(encoded.root, true, 1), + loadClientStatePartition: async (_token, key) => encoded.partitions[key] ?? null, + saveClientState: async () => { writes += 1; return true }, + }) + state.updateRestorableSession(session("overwrite")); await state.flushClientState() + assert.equal(state.loadedRestorableSession(), null) + assert.equal(writes, 0) + }) + + it("retries a rejected partition commit promise", async () => { + let attempts = 0 + const state = await boot({ + loadClientState: async () => loadResult(null, true, 1), + loadClientStatePartition: async () => null, + commitClientStatePartitions: async () => { + attempts += 1 + if (attempts === 1) throw new Error("transient partition failure") + return true + }, + }) + state.updateRestorableSession(session("retry")); await state.flushClientState() + assert.equal(attempts, 2) + }) + + for (const kind of ["malformed root", "missing partition", "hash mismatch"] as const) { + it(`fences writes for a ${kind}`, async () => { + const encoded = await encodeClientSnapshotV2(snapshot("stored")) + let writes = 0 + const state = await boot({ + loadClientState: async () => loadResult(kind === "malformed root" + ? { ...encoded.root, sessionPartition: "bad" } : encoded.root, true, 1), + loadClientStatePartition: async () => kind === "missing partition" ? null : "wrong partition", + commitClientStatePartitions: async () => { writes += 1; return true }, + }) + state.updateRestorableSession(session("overwrite")); await state.flushClientState() + assert.equal(state.loadedRestorableSession(), null) + assert.equal(writes, 0) + }) + } + + for (const kind of ["clear", "disable"] as const) { + it(`keeps ${kind} available while malformed V2 state is fenced`, async () => { + let escapes = 0 + const state = await boot({ + loadClientState: async () => loadResult({ version: 2 }, true, 1), + clearClientState: async () => { escapes += 1; return true }, + setClientStateRestoreEnabled: async () => { escapes += 1; return true }, + }) + await (kind === "clear" ? state.clearRestoredClientState() : state.setRestorePreviousStateEnabled(false)) + assert.equal(escapes, 1) + }) + } +}) diff --git a/packages/ui/src/stores/client-state.ts b/packages/ui/src/stores/client-state.ts index b13376e9..06c7f17e 100644 --- a/packages/ui/src/stores/client-state.ts +++ b/packages/ui/src/stores/client-state.ts @@ -1,8 +1,12 @@ import { createSignal } from "solid-js" -import { clearNativeClientState, loadNativeClientState, saveNativeClientState, setNativeRestoreEnabled } from "../lib/native/client-state" -import { decodeClientSnapshot, isFutureClientSnapshot, normalizeRestorableSession } from "./client-state-codec" +import { clearNativeClientState, commitNativeClientStatePartitions, loadNativeClientState, loadNativeClientStatePartition, + saveNativeClientState, setNativeRestoreEnabled } from "../lib/native/client-state" +import { decodeClientSnapshot, normalizeRestorableSession } from "./client-state-codec" import type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } from "./client-state-codec" +import { canCommitClientSnapshotV2, decodeClientSnapshotV2, encodeClientSnapshotV2, isClientSnapshotV2 } from "./client-state-partitions" +import type { ClientSnapshotV2 } from "./client-state-partitions" export type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } +export type { ClientSnapshotV2 } const SAVE_DEBOUNCE_MS = 250 const FLUSH_MAX_ATTEMPTS = 3 const MAX_LAYOUT_ENTRIES = 64 @@ -24,6 +28,7 @@ let saveTimer: ReturnType | null = null let writeQueue: Promise = Promise.resolve() let destructiveQueue: Promise = Promise.resolve() let lastSaveError: unknown +let partitionProtocolVersion: 1 | undefined const transactionLayoutWrites = new Set() function useLocalStorage(fallback: T, operation: (storage: Storage) => T): T { try { @@ -92,7 +97,17 @@ function enqueuePendingSave(): Promise { dirty = false const saveAttempt = writeQueue.then(async () => { try { - if (!await saveNativeClientState(normalizedSnapshot)) { + const partitioned = partitionProtocolVersion === 1 ? await encodeClientSnapshotV2(normalizedSnapshot) : null + // ponytail: bounded V1 avoids truncation at the native cap; add a denser graph if V1 stops fitting. + const accepted = partitioned && canCommitClientSnapshotV2(partitioned) + ? await commitNativeClientStatePartitions({ + protocolVersion: 1, + snapshot: partitioned.root, + partitions: partitioned.partitions, + partitionKeys: partitioned.partitionKeys, + }) + : await saveNativeClientState(normalizedSnapshot) + if (!accepted) { setClientStateIsPrimary(false) throw new Error("Native client state save was rejected") } @@ -261,6 +276,7 @@ export function initializeClientState(): Promise { initialization = (async () => { try { const loaded = await loadNativeClientState() + partitionProtocolVersion = loaded.partitionProtocolVersion setClientStateIsPrimary(loaded.isPrimary) setRestorePreviousStateEnabledSignal(loaded.restoreEnabled) resetLoadedState(null, true) @@ -270,8 +286,24 @@ export function initializeClientState(): Promise { transactionLayoutWrites.clear() initialized = true if (!loaded.isPrimary || !loaded.restoreEnabled) return - writeBlock = isFutureClientSnapshot(loaded.snapshot) ? "snapshot" : false - const snapshot = decodeClientSnapshot(loaded.snapshot) + let snapshot: ClientSnapshotV1 | null + if (isClientSnapshotV2(loaded.snapshot)) { + writeBlock = "snapshot" + try { + snapshot = await decodeClientSnapshotV2( + loaded.snapshot, + loaded.partitionProtocolVersion, + loadNativeClientStatePartition, + ) + } catch (error) { + snapshot = null + console.warn("[client-state] failed to load client snapshot partition", error) + } + if (snapshot) writeBlock = false + } else { + snapshot = decodeClientSnapshot(loaded.snapshot) + writeBlock = loaded.snapshot !== null && snapshot === null ? "snapshot" : false + } resetLoadedState(snapshot, true) if (!writeBlock && migrateLegacyLayoutValues()) scheduleSave() } catch (error) { diff --git a/packages/ui/src/stores/instances-restore-ownership.test.ts b/packages/ui/src/stores/instances-restore-ownership.test.ts index 077d8e7c..eba0f337 100644 --- a/packages/ui/src/stores/instances-restore-ownership.test.ts +++ b/packages/ui/src/stores/instances-restore-ownership.test.ts @@ -25,11 +25,10 @@ it("only restore-scoped creations are owned by abort cleanup", async () => { } try { - await createInstance("/ordinary", undefined, { activate: false }) + await createInstance("/ordinary", undefined) assert.equal(tracked.some(({ id }) => id === "ordinary"), false) const restored = await createInstance("/restore", undefined, { - activate: false, signal: new AbortController().signal, }) assert.ok(restored.requestId) diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 320aa056..bf6d8a6d 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -64,7 +64,6 @@ import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { clearInstanceMetadata } from "./instance-metadata" import { showWorkspaceLaunchError } from "./launch-errors" -import { activeSidecarToken } from "./sidecars" import { showToastNotification } from "../lib/notifications" import { tGlobal } from "../lib/i18n" import { loadInstanceMetadata } from "../lib/hooks/use-instance-metadata" @@ -77,7 +76,7 @@ import { type FormInfo, } from "./forms" import { invalidateFilesystemCaches } from "../lib/filesystem-events" -import { appSessionRestoreGateActive } from "./app-session-restore-gate" +import { detachInstanceTabMembership, requestInstanceTabClose } from "./app-tab-membership" import { waitForLatestWorkspaceLoadResult } from "./workspace-load-readiness" import { clearInstanceAttachments } from "./attachments" import { publishInstanceLifecycleAuthority } from "./instance-lifecycle-authority" @@ -381,22 +380,6 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor, projectN } } -function ensureActiveInstanceSelected(): void { - if (appSessionRestoreGateActive()) return - if (activeSidecarToken()) return - - const current = activeInstanceId() - const instanceMap = instances() - if (current && instanceMap.has(current)) return - - for (const [id, instance] of instanceMap.entries()) { - if (instance.status === "ready") { - setActiveInstanceId(id) - return - } - } -} - function upsertWorkspace(descriptor: WorkspaceDescriptor, projectName?: string) { const mapped = workspaceDescriptorToInstance(descriptor, projectName) if (instances().has(descriptor.id)) { @@ -407,9 +390,6 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor, projectName?: string) if (descriptor.status === "ready") { attachClient(descriptor) - // If no tab is currently selected (common after UI refresh), - // auto-select the first ready instance. - ensureActiveInstanceSelected() settleInstanceReadyWaiters(descriptor.id) } else if (descriptor.status === "error" || descriptor.status === "stopped") { settleInstanceReadyWaiters( @@ -811,7 +791,6 @@ async function refreshWorkspaceList(): Promise { releaseInstanceResources(instanceId) removeInstance(instanceId, { authoritative: false }) } - ensureActiveInstanceSelected() } finally { workspaceListReconciliationFence.complete(requestFence) } @@ -911,6 +890,7 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) { clearSyncedYoloSessionsForInstance(event.workspace.id) break case "workspace.stopped": + requestInstanceTabClose(event.workspaceId) restoreCreatedWorkspaceCleanup.release(event.workspaceId) releaseInstanceResources(event.workspaceId) removeInstance(event.workspaceId, { authoritative: event.reason === "deleted" }) @@ -1027,6 +1007,7 @@ function updateInstance(id: string, updates: Partial) { } function removeInstance(id: string, options: { authoritative?: boolean } = {}) { + detachInstanceTabMembership(id) connectionResyncGate.clear(id) const removedInstance = instances().get(id) const removedOccurrence = removedInstance @@ -1166,7 +1147,6 @@ async function createInstance( folder: string, projectName?: string, options?: { - activate?: boolean signal?: AbortSignal shouldCreateCommit?: () => boolean onBeforeCreateCommit?: (instanceId: string) => void @@ -1249,7 +1229,6 @@ async function createInstance( options?.onBeforeCreateCommit?.(workspace.id) upsertWorkspace(committedWorkspace, reused ? undefined : projectName) options?.onCreateCommit?.(workspace.id) - if (!reused && (options?.activate ?? true)) setActiveInstanceId(workspace.id) } if (discarded) { if (workspace.requestId) await disposeRestoreWorkspaceResponse(workspace) @@ -1315,28 +1294,31 @@ function updateProjectNameForFolder(folder: string, projectName: string): void { } } -function stopInstance(id: string) { +const stopInstanceRequests = new Map>() + +function stopInstance(id: string): Promise { const instance = instances().get(id) - if (!instance) return + if (!instance) return Promise.resolve() + const pending = stopInstanceRequests.get(id) + if (pending) return pending - workspaceListReconciliationFence.markMutation(id) - releaseInstanceResources(id) - removeInstance(id) - - if (restoreCreatedWorkspaceCleanup.owns(id)) { - void restoreCreatedWorkspaceCleanup.discardTracked(id, { retainTombstone: true }) - .then(() => serverApi.deleteWorkspace(id)) - .catch((error) => log.error("Failed to stop restore-tracked workspace", error)) - return - } - - void serverApi.deleteWorkspace(id).catch((error) => { - log.error("Failed to stop workspace", error) - showToastNotification({ - message: tGlobal("app.stopInstance.toast.error"), - variant: "error", + const request = serverApi.deleteWorkspace(id) + .catch((error) => { + log.error("Failed to stop workspace", error) + try { + showToastNotification({ + message: tGlobal("app.stopInstance.toast.error"), + variant: "error", + }) + } finally { + throw error + } }) - }) + .finally(() => { + stopInstanceRequests.delete(id) + }) + stopInstanceRequests.set(id, request) + return request } function getActiveInstance(): Instance | null { @@ -1791,13 +1773,7 @@ async function acknowledgeDisconnectedInstance(): Promise { return } - try { - stopInstance(pending.id) - } catch (error) { - log.error("Failed to stop disconnected instance", error) - } finally { - setDisconnectedInstance(null) - } + setDisconnectedInstance(null) } export { diff --git a/packages/ui/src/stores/message-prompt-display.test.ts b/packages/ui/src/stores/message-prompt-display.test.ts index 3cbaa93c..c0300495 100644 --- a/packages/ui/src/stores/message-prompt-display.test.ts +++ b/packages/ui/src/stores/message-prompt-display.test.ts @@ -14,12 +14,14 @@ import { class MemoryStorage { private entries = new Map() + failWrites = false getItem(key: string): string | null { return this.entries.has(key) ? this.entries.get(key)! : null } setItem(key: string, value: string): void { + if (this.failWrites) throw new Error("write failed") this.entries.set(key, value) } @@ -33,6 +35,7 @@ class MemoryStorage { } type WindowWithMemoryStorage = { + __CODENOMAD_WINDOW_ID__?: string localStorage: { getItem(key: string): string | null setItem(key: string, value: string): void @@ -187,4 +190,72 @@ describe("message prompt display overrides", () => { delete (globalThis as unknown as { window?: unknown }).window }) + + it("keeps native window storage maps independent", () => { + const storage = new MemoryStorage() + const metadataA: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 1 }] } + const metadataB: PromptDisplayMetadata = { segments: [{ kind: "pasted", length: 2 }] } + + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { + localStorage: storage, + __CODENOMAD_WINDOW_ID__: "window-a", + } + resetPromptDisplayOverrideStateForTests() + setPromptDisplayOverride("instance", "session", "message", metadataA) + + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { + localStorage: storage, + __CODENOMAD_WINDOW_ID__: "window-b", + } + resetPromptDisplayOverrideStateForTests() + setPromptDisplayOverride("instance", "session", "message", metadataB) + assert.deepEqual(getPromptDisplayOverride("instance", "session", "message"), metadataB) + + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { + localStorage: storage, + __CODENOMAD_WINDOW_ID__: "window-a", + } + resetPromptDisplayOverrideStateForTests() + assert.deepEqual(getPromptDisplayOverride("instance", "session", "message"), metadataA) + assert.notEqual(storage.getItem("codenomad:prompt-display:v3:window-a"), storage.getItem("codenomad:prompt-display:v3:window-b")) + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("migrates unsuffixed v3 into an absent native window key exactly once", () => { + const storage = new MemoryStorage() + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 3 }] } + storage.setItem("codenomad:prompt-display:v3", JSON.stringify({ "session:message": metadata })) + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { + localStorage: storage, + __CODENOMAD_WINDOW_ID__: "window-a", + } + resetPromptDisplayOverrideStateForTests() + + assert.deepEqual(getPromptDisplayOverride("instance", "session", "message"), metadata) + assert.equal(storage.getItem("codenomad:prompt-display:v3"), null) + assert.equal(storage.getItem("codenomad:prompt-display:v3:window-a") !== null, true) + + storage.setItem("codenomad:prompt-display:v3", JSON.stringify({ "other:message": metadata })) + resetPromptDisplayOverrideStateForTests() + assert.equal(getPromptDisplayOverride("instance", "other", "message"), undefined) + assert.equal(storage.getItem("codenomad:prompt-display:v3") !== null, true) + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("keeps unsuffixed v3 when scoped migration persistence fails", () => { + const storage = new MemoryStorage() + const metadata: PromptDisplayMetadata = { segments: [{ kind: "pasted", length: 5 }] } + storage.setItem("codenomad:prompt-display:v3", JSON.stringify({ "session:message": metadata })) + storage.failWrites = true + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { + localStorage: storage, + __CODENOMAD_WINDOW_ID__: "window-a", + } + resetPromptDisplayOverrideStateForTests() + + assert.deepEqual(getPromptDisplayOverride("instance", "session", "message"), metadata) + assert.equal(storage.getItem("codenomad:prompt-display:v3") !== null, true) + assert.equal(storage.getItem("codenomad:prompt-display:v3:window-a"), null) + delete (globalThis as unknown as { window?: unknown }).window + }) }) diff --git a/packages/ui/src/stores/message-prompt-display.ts b/packages/ui/src/stores/message-prompt-display.ts index 8b6d395a..01cfe26e 100644 --- a/packages/ui/src/stores/message-prompt-display.ts +++ b/packages/ui/src/stores/message-prompt-display.ts @@ -6,6 +6,11 @@ const LEGACY_STORAGE_KEY = "codenomad:prompt-display:v2" let loaded = false const promptDisplayOverrides = new Map() +function storageKey(): string { + const windowId = typeof window === "undefined" ? "" : window.__CODENOMAD_WINDOW_ID__?.trim() + return windowId ? `${STORAGE_KEY}:${windowId}` : STORAGE_KEY +} + function makeKey(_instanceId: string, sessionId: string, messageId: string): string { return `${sessionId}:${messageId}` } @@ -40,16 +45,26 @@ function ensureLoaded(): void { if (!storage) return try { - const raw = storage.getItem(STORAGE_KEY) + const key = storageKey() + const raw = storage.getItem(key) if (raw) { loadStoredEntries(JSON.parse(raw) as Record, false) } - const legacyRaw = storage.getItem(LEGACY_STORAGE_KEY) - if (legacyRaw) { - loadStoredEntries(JSON.parse(legacyRaw) as Record, true) + const migrateIntoScopedKey = key !== STORAGE_KEY && !raw + const legacyV3Raw = migrateIntoScopedKey ? storage.getItem(STORAGE_KEY) : null + if (legacyV3Raw) { + loadStoredEntries(JSON.parse(legacyV3Raw) as Record, false) } - if (!raw && !legacyRaw) return - if (persist() && legacyRaw) storage.removeItem(LEGACY_STORAGE_KEY) + const legacyV2Raw = key === STORAGE_KEY || migrateIntoScopedKey + ? storage.getItem(LEGACY_STORAGE_KEY) + : null + if (legacyV2Raw) { + loadStoredEntries(JSON.parse(legacyV2Raw) as Record, true) + } + if (!legacyV3Raw && !legacyV2Raw) return + if (!persist()) return + if (legacyV3Raw) storage.removeItem(STORAGE_KEY) + if (legacyV2Raw) storage.removeItem(LEGACY_STORAGE_KEY) } catch { promptDisplayOverrides.clear() } @@ -68,7 +83,7 @@ function persist(): boolean { if (!storage) return false try { - storage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(promptDisplayOverrides))) + storage.setItem(storageKey(), JSON.stringify(Object.fromEntries(promptDisplayOverrides))) return true } catch { // Ignore persistence failures. diff --git a/packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts b/packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts index 563e5465..5e241963 100644 --- a/packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts +++ b/packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts @@ -16,7 +16,8 @@ describe("workspace list reconciliation fence", () => { assert.ok(create.indexOf("workspaceListReconciliationFence.markMutation(workspace.id)") < create.indexOf("upsertWorkspace(committedWorkspace")) const stop = source.slice(source.indexOf("function stopInstance"), source.indexOf("async function fetchLspStatus")) - assert.ok(stop.indexOf("workspaceListReconciliationFence.markMutation(id)") < stop.indexOf("removeInstance(id)")) + assert.equal(stop.includes("removeInstance(id)"), false) + assert.equal(stop.includes("serverApi.deleteWorkspace(id)"), true) }) it("rejects stale list entries and absences after lifecycle mutations", () => { diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index b922c343..70cc48c8 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -26,6 +26,14 @@ declare global { isPrimary: boolean restoreEnabled: boolean snapshot: unknown | null + partitionProtocolVersion?: 1 + } + + interface ClientStatePartitionCommit { + protocolVersion: 1 + snapshot: unknown + partitions: Record + partitionKeys: string[] } interface ElectronAPI { @@ -43,6 +51,10 @@ declare global { editor?: "vscode" | "cursor" | "zed" | "vscodium" }) => Promise<{ ok: true }> setWorkspaceMenuEnabled?: (enabled: boolean) => Promise<{ ok: true }> + newWindow?: () => Promise<{ ok: true }> + nextPendingFolder?: () => Promise + acknowledgePendingFolder?: (folder: string, opened: boolean) => Promise<{ ok: true }> + onPendingFolders?: (callback: () => void) => () => void onMenuAction?: (callback: (action: string) => void) => () => void getPathForFile?: (file: File) => string | null requestMicrophoneAccess?: () => Promise<{ granted: boolean }> @@ -50,6 +62,8 @@ declare global { claimClientStateAccess?: (accessToken: string) => Promise loadClientState?: (accessToken: string) => Promise saveClientState?: (accessToken: string, snapshot: unknown) => Promise + commitClientStatePartitions?: (accessToken: string, payload: ClientStatePartitionCommit) => Promise + loadClientStatePartition?: (accessToken: string, key: string) => Promise setClientStateRestoreEnabled?: (accessToken: string, enabled: boolean) => Promise clearClientState?: (accessToken: string) => Promise @@ -81,6 +95,9 @@ declare global { core?: { invoke: (cmd: string, args?: Record) => Promise } + event?: { + listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> + } } interface Window { @@ -88,6 +105,7 @@ declare global { __CODENOMAD_EVENTS_URL__?: string __CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web" __CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote" + readonly __CODENOMAD_WINDOW_ID__?: string | null __CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__?: () => Promise electronAPI?: ElectronAPI __TAURI__?: TauriBridge