diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index bb35018a8..a5b608480 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -395,6 +395,9 @@ jobs: working-directory: packages/tauri-app/src-tauri run: CODENOMAD_TEST_ZSH=/bin/zsh CODENOMAD_TEST_NODE="$(command -v node)" cargo test --locked + - name: Test owned Tauri zoom input script + run: node --test packages/tauri-app/tests/window-zoom-script.test.mjs + tests-tauri-windows: needs: authorize if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} @@ -429,6 +432,9 @@ jobs: working-directory: packages/tauri-app/src-tauri run: cargo test --locked -- --test-threads=1 + - name: Test owned Tauri zoom input script + run: node --test packages/tauri-app/tests/window-zoom-script.test.mjs + - name: Reproduce and prevent Tao input deadlocks run: node scripts/test-tauri-input-deadlock.mjs --baseline @@ -542,6 +548,16 @@ jobs: working-directory: packages/tauri-app/src-tauri run: CODENOMAD_TEST_ZSH=/bin/zsh CODENOMAD_TEST_NODE="$(command -v node)" cargo test --locked -- --test-threads=4 + - name: Reproduce and prevent macOS window event feedback + timeout-minutes: 10 + run: | + node --test scripts/test-tauri-macos-window-result.test.mjs + node scripts/test-tauri-macos-window.mjs --baseline + node scripts/test-tauri-macos-window.mjs --baseline + + - name: Test owned Tauri zoom input script + run: node --test packages/tauri-app/tests/window-zoom-script.test.mjs + - name: Test desktop shell startup on macOS env: CODENOMAD_TEST_ZSH: /bin/zsh diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index 690eabc74..2527fac19 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@opencode/plugin": "2.0.18" + "@opencode/plugin": "2.0.21" } }, "node_modules/@ai-sdk/provider": { @@ -914,13 +914,13 @@ } }, "node_modules/@opencode/ai": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.18.tgz", - "integrity": "sha512-zzd01WCh4rdvA29DwUVhG3n8rnn4y8jAobBE34ot5ueRmljO8AZ61/fq0+89tcm2OtEkGoMiAoBALJqd2LaWtQ==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.21.tgz", + "integrity": "sha512-eB4M0O44VoX075Kv+vIVxIpiKSrfjSXpNaeBSjtPgZdhZCjKN5CxjJXtyPbhrDxvmDf0PxLcvjGNKQBRMkZ0fw==", "license": "MIT", "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", - "@opencode/schema": "2.0.18", + "@opencode/schema": "2.0.21", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", @@ -929,13 +929,13 @@ } }, "node_modules/@opencode/client": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.18.tgz", - "integrity": "sha512-N74FNdXkTTC1Re5T+PNxH6fsVf1tZiV3kESu3UWL/545oAkBRXoiikMmDszuuHZ4AxGwtr9NiQjKrHiMdnLVYQ==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.21.tgz", + "integrity": "sha512-cX9sj9qpaTlkVfdaeLCFeV43+1mcSHVhvgjkVLCYyfQJj0MmBGgx/x9AM/GHVZDEX6T2mUljn0Qh5XhFM9CgrQ==", "license": "MIT", "dependencies": { - "@opencode/protocol": "2.0.18", - "@opencode/schema": "2.0.18" + "@opencode/protocol": "2.0.21", + "@opencode/schema": "2.0.21" }, "peerDependencies": { "effect": "4.0.0-rc.112", @@ -951,25 +951,25 @@ } }, "node_modules/@opencode/plugin": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.18.tgz", - "integrity": "sha512-jokYYdAGd/L+WY0GbEE1Bh1/F8tG98UaTlvvyBriOuVDyeadHFdTL9QoXx+u7pAWwJRTEMOESxxifS/blGPjyg==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.21.tgz", + "integrity": "sha512-ZLagbklGyYPg77GvK//ir3LaNXBsZQENU8APCnaT4elsBk7YzoVt8stR6+b4wH7Sfadw76cWSaNiWgDo2QOSHA==", "license": "MIT", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@opencode/ai": "2.0.18", - "@opencode/client": "2.0.18", - "@opencode/protocol": "2.0.18", - "@opencode/schema": "2.0.18", - "@opencode/util": "2.0.18", + "@opencode/ai": "2.0.21", + "@opencode/client": "2.0.21", + "@opencode/protocol": "2.0.21", + "@opencode/schema": "2.0.21", + "@opencode/util": "2.0.21", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { - "@opencode/theme": "2.0.18", - "@opentui/core": ">=0.5.12", - "@opentui/solid": ">=0.5.12", + "@opencode/theme": "2.0.21", + "@opentui/core": ">=0.5.14", + "@opentui/solid": ">=0.5.14", "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { @@ -988,19 +988,19 @@ } }, "node_modules/@opencode/protocol": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.18.tgz", - "integrity": "sha512-IhzzlCz/hAlFvBLWQ+kDceX0/CkYOrjVyTPwYJYvWwKze5AHzVxxDYxkhCqLJj0hn/wtdNL10nhjVothuHcaBQ==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.21.tgz", + "integrity": "sha512-KvcpCPpIXPMkJaffUFwLxUAZwf6SiNf/8qFW/ZR7LEHFBcnmzyl7hPk5QthAHmnTRoR+dU2U/WMakj06ePRPyg==", "license": "MIT", "dependencies": { - "@opencode/schema": "2.0.18", + "@opencode/schema": "2.0.21", "effect": "4.0.0-rc.112" } }, "node_modules/@opencode/schema": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.18.tgz", - "integrity": "sha512-f+lp6f+kxhygdeSxjqkiu6vk9t0onH/6OVBghSNyo9ZUUFP9BU/2JKthMgy9kHGyFu2vp0lg01H9umXn76u52A==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.21.tgz", + "integrity": "sha512-HMIU9sCNOvbMWQeslxbTtCLfvhMyar40+VHkZq9hNjXTaPWJrGxSzbuHJQhKzeOeDaiP7t1zllUDHTxTA/5SOw==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", @@ -1008,9 +1008,9 @@ } }, "node_modules/@opencode/util": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.18.tgz", - "integrity": "sha512-PmrOwB3k9nlubvM9ZIj08uann/SpPAfBt6fPI4OiRCnNKBNkEbU4d3CZIFL3QtxTCn1OPzF/AfRDQUuE4Tf+Mg==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.21.tgz", + "integrity": "sha512-wQaa4Q19zbK+B76Pzf2NV3eE8orwWw7IDlBi/Y4WpUaHwaaF+fCQGQxkByyOeWmSy38IQEX93c8RyLPB+WWjog==", "license": "MIT", "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", @@ -1252,26 +1252,23 @@ } }, "node_modules/@redis/bloom": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.2.1.tgz", - "integrity": "sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.3.0.tgz", + "integrity": "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA==", "license": "MIT", "engines": { "node": ">= 20.0.0" }, "peerDependencies": { - "@redis/client": "^6.2.1" + "@redis/client": "^6.3.0" } }, "node_modules/@redis/client": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.2.1.tgz", - "integrity": "sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.3.0.tgz", + "integrity": "sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==", "license": "MIT", "peer": true, - "dependencies": { - "cluster-key-slot": "1.1.2" - }, "engines": { "node": ">= 20.0.0" }, @@ -1289,39 +1286,39 @@ } }, "node_modules/@redis/json": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.2.1.tgz", - "integrity": "sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.3.0.tgz", + "integrity": "sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw==", "license": "MIT", "engines": { "node": ">= 20.0.0" }, "peerDependencies": { - "@redis/client": "^6.2.1" + "@redis/client": "^6.3.0" } }, "node_modules/@redis/search": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.2.1.tgz", - "integrity": "sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.3.0.tgz", + "integrity": "sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw==", "license": "MIT", "engines": { "node": ">= 20.0.0" }, "peerDependencies": { - "@redis/client": "^6.2.1" + "@redis/client": "^6.3.0" } }, "node_modules/@redis/time-series": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.2.1.tgz", - "integrity": "sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.3.0.tgz", + "integrity": "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A==", "license": "MIT", "engines": { "node": ">= 20.0.0" }, "peerDependencies": { - "@redis/client": "^6.2.1" + "@redis/client": "^6.3.0" } }, "node_modules/@sigstore/bundle": { @@ -1399,9 +1396,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz", - "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", + "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.19.0", @@ -1583,9 +1580,9 @@ } }, "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -1777,15 +1774,6 @@ "node": ">=8" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/cmd-shim": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", @@ -3153,17 +3141,17 @@ } }, "node_modules/redis": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/redis/-/redis-6.2.1.tgz", - "integrity": "sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-6.3.0.tgz", + "integrity": "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw==", "license": "MIT", "peer": true, "dependencies": { - "@redis/bloom": "6.2.1", - "@redis/client": "6.2.1", - "@redis/json": "6.2.1", - "@redis/search": "6.2.1", - "@redis/time-series": "6.2.1" + "@redis/bloom": "6.3.0", + "@redis/client": "6.3.0", + "@redis/json": "6.3.0", + "@redis/search": "6.3.0", + "@redis/time-series": "6.3.0" }, "engines": { "node": ">= 20.0.0" @@ -3775,13 +3763,13 @@ } }, "node_modules/wsl-utils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", - "integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", "license": "MIT", "dependencies": { "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" + "powershell-utils": "^0.2.0" }, "engines": { "node": ">=20" @@ -3790,18 +3778,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/wsl-utils/node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/.opencode/package.json b/.opencode/package.json index 1d639aa32..663086ef4 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -5,6 +5,6 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@opencode/plugin": "2.0.18" + "@opencode/plugin": "2.0.21" } } diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index dedfcc3d0..8960d90f2 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -18,7 +18,7 @@ description: | - Runtime requirements live in `opencode/runtime-support.ts`; the technical minimum is 2.0.7 for native step-start timestamps and each blocking requirement needs demonstrated API/behavior evidence. Distinguish technically incompatible, recommended/tested and unverified versions. Setup uses bundled Node/npm for a shared user npm installation and prefers PATH; the retired private installation must not be discovered or launched. Shared-daemon restart is explicit and uses the existing native-parent launch bridge. Retire obsolete wire translations while retaining current identity/authority checks. See `dev-docs/OPENCODE_V2_POST_BETA.md` for precise retirement boundaries and validation evidence. -- Server and UI pin the official `@opencode/client@2.0.18` together; bundled plugins pin `@opencode/plugin@2.0.18`. The canonical server-info client is `server.info()`, adapted to the authenticated discovered route. Review official V2 docs, installed declarations, generated wire paths and native integration tests when upgrading. Qualify against the latest published stable runtime, but never infer the minimum from that version or from the dependency pin. Record technical minimum requirements and tested scenarios in PR/CI logs and update `dev-docs/OPENCODE_V2_COMPATIBILITY.md` in place rather than adding per-version reports. The runtime CLI is managed independently. +- Server and UI pin the official `@opencode/client@2.0.21` together; bundled plugins pin `@opencode/plugin@2.0.21`. The canonical server-info client is `server.info()`, adapted to the authenticated discovered route. Review official V2 docs, installed declarations, generated wire paths and native integration tests when upgrading. Qualify against the latest published stable runtime, but never infer the minimum from that version or from the dependency pin. Record technical minimum requirements and tested scenarios in PR/CI logs and update `dev-docs/OPENCODE_V2_COMPATIBILITY.md` in place rather than adding per-version reports. The runtime CLI is managed independently. - Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode/client` declarations. - There is no legacy `packages/opencode-plugin/`. Do not restore the V1 compatibility runtime or add general plugin extension points. The narrow integrations are the bundled `codenomad.automation` plugin, project-local native session coordination (`codenomad.missions`), and bundled session-pruning RPC; see `dev-docs/DEVELOPER_MODE.md`, `dev-docs/BROWSER_AUTOMATION.md`, `dev-docs/MISSIONS.md` and `dev-docs/SESSION_PRUNING_RPC.md`. All automation tools follow backend presence without a Developer Mode gate, sharing the authenticated native transport and execution-time session/window fences. - 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. diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index b9bc95bcf..cdae0b7ca 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -20,7 +20,7 @@ Client-state V3 is a per-window envelope over the V2 content-addressed partition | Owner | Responsibilities | Main paths | |---|---|---| -| OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive PTYs | pinned stable `@opencode/client@2.0.18` contract across server and UI | +| OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive PTYs | pinned stable `@opencode/client@2.0.21` contract across server and UI | | CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing, Developer Mode bridge | `packages/server/src/` | | CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` | | Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` | 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 d20988345..ed0261088 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -2,9 +2,9 @@ ## Package -CodeNomad server and UI pin `@opencode/client@2.0.18`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404 with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. The shared transport maps canonical `server.info()` to the discovered route. Older services do not expose `paths.tmp`; consumers may use only the metadata actually provided. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading. +CodeNomad server and UI pin `@opencode/client@2.0.21`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404 with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. The shared transport maps canonical `server.info()` to the discovered route. Older services do not expose `paths.tmp`; consumers may use only the metadata actually provided. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading. -Runtime requirements live in `packages/server/src/opencode/runtime-support.ts`: minimum 2.0.7 for native step-start timestamps, independently of recommended/tested 2.0.18. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Admission precedes functional requests/plugin provisioning; unlisted versions, including custom/prerelease/future labels, require authenticated bounded API recognition. Legacy request/response/event and live location translations are retired. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_POST_BETA.md` for precise boundaries, setup and migration evidence. +Runtime requirements live in `packages/server/src/opencode/runtime-support.ts`: minimum 2.0.7 for native step-start timestamps, independently of recommended/tested 2.0.21. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Admission precedes functional requests/plugin provisioning; unlisted versions, including custom/prerelease/future labels, require authenticated bounded API recognition. Legacy request/response/event and live location translations are retired. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_POST_BETA.md` for precise boundaries, setup and migration evidence. The Promise client preserves the full `baseUrl` path prefix. Forward its generated URL unchanged through `createInstanceFetch`; remove the proxy prefix only when classifying reads for scheduling. Declared API errors are `Error` instances retaining their native `_tag`/data fields. Client errors retain structured `reason`/`cause`; 2.0.16 adds status, content type or cause detail to `message`. Classify by structured fields rather than exact message text. 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 b04019328..0a9aab076 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -2,12 +2,12 @@ ## Contract -- Server and UI pin `@opencode/client@2.0.18`. Manage the runtime CLI independently: startup checks authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404. All probes share the endpoint, credentials, 64 KiB response bound and absolute deadline; authentication, transport and malformed response failures do not trigger fallback. The shared transport maps canonical `server.info()` to the discovered route. Older services do not provide `paths.tmp`; never infer that path from the backend host. Discovery alone does not prove client/API compatibility. Review documentation, installed declarations and proxy/API parity whenever the client contract changes. +- Server and UI pin `@opencode/client@2.0.21`. Manage the runtime CLI independently: startup checks authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404. All probes share the endpoint, credentials, 64 KiB response bound and absolute deadline; authentication, transport and malformed response failures do not trigger fallback. The shared transport maps canonical `server.info()` to the discovered route. Older services do not provide `paths.tmp`; never infer that path from the backend host. Discovery alone does not prove client/API compatibility. Review documentation, installed declarations and proxy/API parity whenever the client contract changes. - The package root is the generated zero-Effect Promise client. Use installed declarations, not current public `@opencode-ai/sdk` examples. - Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy. - That proxy is an explicit method/path allowlist. Future upstream APIs are not exposed automatically. - Proxy authorization and forwarding share one acquired connection. A stale generation must be rejected at actual HTTP dispatch, including after asynchronous body preparation; late streams cannot invalidate a replacement connection. -- The technical minimum is 2.0.7: native `session.step.started.data.started` is consumed directly after removing its older fallback. Recommendation/qualification 2.0.18 is independent. Unknown version labels, prereleases and future majors require bounded authenticated API recognition, including session environment support; they are not rejected solely by label. Legacy HTTP inbox and event conversions are retired. Native `session.inbox.enqueued` still has a distinct shape: its timestamp belongs to event metadata, not an HTTP inbox record. +- The technical minimum is 2.0.7: native `session.step.started.data.started` is consumed directly after removing its older fallback. Recommendation/qualification 2.0.21 is independent. Unknown version labels, prereleases and future majors require bounded authenticated API recognition, including session environment support; they are not rejected solely by label. Legacy HTTP inbox and event conversions are retired. Native `session.inbox.enqueued` still has a distinct shape: its timestamp belongs to event metadata, not an HTTP inbox record. - Never retry a write using another contract after a 400/404/transport failure. Keep the setup/recovery path distinct from functional transport and never replay prompts. ## Location Is Authority 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 43b31b586..3b48a6bc3 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -10,7 +10,7 @@ OpenCode owns standard state/database. Allowed configured environment variables Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory. Its `project` field supplies project metadata. Public locations have no workspace selector. 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 technical minimum is 2.0.7 for the native step-start timestamp, with 2.0.18 independently recommended/tested; old request/response/event and live location translations are retired. Preserve historical `workspaceID` validation internally: obsolete public selectors must be rejected rather than erased from imports, cursors, pending Forms or move rollback. Native historical migrations collapse workspace selectors to local directory scope while preserving session IDs. See `dev-docs/OPENCODE_V2_COMPATIBILITY.md` for current qualification and `dev-docs/OPENCODE_V2_POST_BETA.md` for the transition record. +The technical minimum is 2.0.7 for the native step-start timestamp, with 2.0.21 independently recommended/tested; old request/response/event and live location translations are retired. Preserve historical `workspaceID` validation internally: obsolete public selectors must be rejected rather than erased from imports, cursors, pending Forms or move rollback. Native historical migrations collapse workspace selectors to local directory scope while preserving session IDs. See `dev-docs/OPENCODE_V2_COMPATIBILITY.md` for current qualification and `dev-docs/OPENCODE_V2_POST_BETA.md` for the transition record. 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/AGENTS.md b/AGENTS.md index 626d8d5e2..7998acc5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,12 +7,16 @@ - Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size. - Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere. - Use the shared `.window-*` primitives from `src/styles/components/window.css` for dialog, popover, and floating-window headers, toolbars, bodies, footers, titles, and actions. +- Persistent utility windows and transcript-covering previews expose an upper-right close button in their existing top toolbar; do not add a dedicated row just for dismissal. Use `components/window-close-button.tsx` with shared window chrome. Transient popups such as transcript filters dismiss on outside interaction and have no close button. - Authentication recovery uses `lib/auth-recovery.ts` and `components/auth-recovery-dialog.tsx`: confirm CodeNomad auth status before showing an expired-login form, reconnect in place to preserve drafts, and never replay failed mutations. Its shared window styles live in `styles/components/auth-recovery.css`. - Persistent command/search utility windows use `components/dismissible-window.tsx`: non-modal, no scrim, outside interactions keep them open, and explicit toggles use `.icon-toggle` with `aria-expanded`/`aria-controls`. Keep search state scoped to its instance/session and close it when that view becomes inactive. - The composer reserves `/btw` for native `session.generate`, outside ordinary prompt/command submission. Its ephemeral question/answer window uses `DismissibleWindow` and `styles/components/session-aside.css`; cancellation and inactive/session transitions fence late results without interrupting the main session. - OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`. - Full-history search/counts use the bundled pruning plugin's bounded queries, outside the transcript store. Current-session results and the global timeline navigate through bounded anchor windows; other-session previews fetch one native message only. Search/results/progress styles live in `styles/components/history-search.css`. See `dev-docs/SESSION_HISTORY_QUERIES.md` for scope, snapshot and cleanup semantics. - Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints. +- Composer context selectors and actions always share one row on desktop and touch; truncate selector labels rather than wrapping the footer. Dense conversation toolbar/footer controls use `--touch-target-size-compact`; timeline markers use the compact control height rather than the global touch target. Toolbar touch spacing lives in `styles/panels/session-toolbar-touch.css`. Transcript filters join the header action overflow and anchor/focus back to that menu when collapsed. +- Composer height limits use the same visible conversation height: minimum 8% (44 px floor), maximum 60% (never below that minimum). Width and draft length do not change the limits. `prompt-input/usePromptViewport.ts` observes the conversation and its intersection with `visualViewport`, including keyboard resize/pan. Store manually chosen heights as proportions so the field itself follows viewport changes; migrate legacy pixel choices on the first valid measurement. Viewport-driven changes never overwrite the saved proportion; pointer and keyboard resize controls share the same limits and long drafts remain scrollable. +- Browser preview retains its six original fixed templates (responsive, desktop, tablet portrait/landscape, mobile portrait/landscape), without a separate rotation button. Mobile templates automatically use native Android/Chromium touch emulation; iframe fallback changes dimensions only. Profiles share `packages/ui/src/lib/native/browser-emulation.json`; only owned native guests may receive device overrides, and returning to dimensions clears them before reloading. Main local/remote desktop content minimums are 390 × 600 CSS pixels: multiply by application zoom, not monitor DPI, and round up native logical dimensions. Restore uses the saved zoom; keyboard/wheel/menu changes update constraints without shrinking larger windows, with monitor-work-area caps at extreme zoom. Electron handles outer-frame offsets in `window-state.ts`; Tauri uses `window_constraints.rs`. Preferences and native preview children retain their separate sizing contracts. - Composer attachments use one native-device action and project-scoped `@` references. Picker/drop/clipboard bytes share `prompt-input/useDeviceAttachments.ts` for serialized budgets and epoch-based draft/focus fencing; keep lifecycle and read logic out of `prompt-input.tsx`. File listing/search validate the session directory through existing workspace ownership before reading a worktree or translated WSL root. - Session rows keep actions inline until their measured title, badges, and controls no longer fit. Keep responsive action styles in `styles/components/session-row-actions.css`; hidden inline controls remain measurable but inert, and an open overflow menu stays mounted until dismissal. - Session hierarchy geometry lives in `styles/components/session-tree.css`; connector axes follow the parent expander at every depth, including selection mode, RTL and touch layouts. @@ -22,7 +26,7 @@ - Tags and numeric/context/token labels also use rounded geometry via `--chip-radius` (`--pill-radius` is an alias). Register badge variants in `styles/components/badges.css`; use `.badge-shape` for utility-styled labels rather than adding a local radius. - The message-content popup and Chat settings share `components/transcript-visibility.ts` and the semantic icons from `components/message-content-icons.ts`; tool presentation metadata lives independently of renderers in `components/tool-call/tool-presentation.ts`. Popup styles live in `styles/components/transcript-filters.css`. - Tool-result images retain native `state.content` and render through the shared `components/tool-call/output-images.tsx` surface, including MCP and specialized tools. Keep image bytes out of text projections (copy/search/speech); image styles live in `styles/messaging/tool-call/images.css`. -- Session timeline placement spans the transcript and composer via the session-owned mount; keep its rail layout in `styles/messaging/session-timeline-rail.css` and preserve compact-layout hiding. +- Session timeline placement spans the transcript and composer via the session-owned mount; keep its rail layout in `styles/messaging/session-timeline-rail.css`. Hide the rail and force conversation-header actions into their overflow menu below 420 CSS px of the `session-center` container on all platforms. Keep that width-only force separate from measured header density so trial layouts can expand again above the breakpoint; filters anchor and return focus to the visible menu. Docked drawers reserve a minimum conversation width of 390 CSS px before overlaying. The composer worktree selector becomes a compact arrow-only cell below 460 CSS px of that conversation container (not the remaining composer width); keep its accessible label and styles in `styles/components/prompt-context-controls.css`. Timeline visibility must not depend on measured header density, changing status controls or viewport height; preserve the saved visibility preference. - Timeline geometry uses the cached structural index independently of transcript/excerpt loading. Aggregate tool markers retain the outline's representative tool name so their icon does not fall back to `other`; preserve that metadata through projection and persisted-index revalidation. Hover/focus previews render bounded Markdown, prefetch visible-nearby excerpts and prioritize the hovered marker through `stores/timeline-previews.ts`; never mount whole message/tool cards or fetch transcript windows for hover. Keep the viewport-bounded surface in `styles/messaging/timeline-preview.css`. - Document any new styling conventions or directory additions in this file so future changes remain consistent. - Soft palette families live in `packages/ui/src/lib/soft-color-schemes.ts`, with references in `dev-docs/PALETTE_SOURCES.md`. Keep selection independent of participant identity, and keep transcript/composer surfaces distinct. Run `palette-quality.test.ts` and inspect real rendered captures when changing palette colors or their token mapping. @@ -41,9 +45,16 @@ - A durable mission report and its coordinator notification are distinct. Recover only unacknowledged report notifications through the existing bridge with stable native message IDs and lifecycle fencing; never turn notification recovery into task dispatch or a workflow engine. An admission acknowledgement is not proof of model consumption or mission completion. - Optional mission specialist cleanup records immutable tombstone targets and per-session receipts. Use the authenticated bridge and authoritative `cleanupTarget` RPC before native HTTP deletion; plugin contexts do not expose native session removal. Preserve coordinators, reused/shared/moved sessions and roots with descendants. Partial cleanup retries retain the original request identity and never become a background dispatcher. - Mission Play starts/resumes through durable explicit lifecycle intent; Pause interrupts registered root actors and parks Mission notifications/dispatch, while Stop is terminal. Native acknowledgements have per-target receipts and explicit retries, with the same authenticated bridge, environment and ownership fences. Do not claim recursive subagent suspension or turn notification recovery into a control-action runner. Keep the single Play/Pause/Stop strip in `components/mission-lifecycle-controls.tsx`. +- Workspace text remains editable in the central file reader (including Markdown source), with Save and Ctrl/Cmd+S. Keep dirty drafts in `components/workspace-file-editor.ts`, scoped by instance/directory/path; invalidation must not replace them. Saves reread for external-change confirmation and pass the exact owned directory through the deletion-fenced filesystem write route. Images, binary content and historical diffs remain read-only. + +- Files row selection never opens the central preview implicitly. All three modes reuse `FileRowActions` with a pinned eye using the shared `icon-toggle` active state; secondary actions use whole-row overflow measurements. Keep selection beneath the entire row including controls and rollover in `styles/panels/control-hover.css`. Changes keeps staged/unstaged disclosures with commit actions at the top of the staged section. Git image readers use exact bounded HEAD/index/worktree or parent/commit bytes, not current-file substitutes for historical content. + +- The Files panel combines Workspace, Changes and Commits in one compact switch, initially on Workspace. Its lazy worktree tree preserves expanded folders while switching modes; tree styles live in `styles/panels/workspace-tree.css`, Git navigation in `styles/panels/git-history.css`, row actions in `styles/panels/file-row-actions.css`. Workspace secondary actions are folder-specific (open, terminal, copy path) and file-specific (open, reveal, copy path). Changes rows carry explicit stage/unstage actions and drag between staged/unstaged drop zones. File/diff readers close with a trailing X; the unified/split switch shows the current layout with Columns/Rows iconography. All three modes are cache-first: directory listings, git status and commit history revalidate lazily on filesystem invalidation, mode activation or explicit refresh, never on every focus or expand. `components/files-preview-view.tsx` chooses the central editable Workspace reader or shared read-only diff reader above the composer. Worktree browsing never checks out a branch or moves a session. Historical reads use bounded worker Git commands behind existing worktree ownership checks. Workspace previews use the bounded directory-authorized preview route; Monaco tokenizers ship locally. See `dev-docs/FILES_PANEL.md` for lifecycle, migration and validation. - Tauri's Tao Windows input backport lives in `packages/tauri-app/vendor/`; preserve upstream provenance and avoid message pumping under input mutexes. Run `node scripts/test-tauri-input-deadlock.mjs --baseline` on Windows when changing it. See `dev-docs/TAURI_WINDOWS_INPUT_DEADLOCK.md` for the captured failure and override removal criteria. +- Tao's macOS zoom getter must remain read-only: synchronous temporary style-mask changes on borderless windows generate geometry-event feedback and can starve startup navigation. Keep the exact upstream tao#1182 backport beside the Windows fix, with its provenance in `packages/tauri-app/vendor/README.md`. Run `node scripts/test-tauri-macos-window.mjs --baseline` in a graphical macOS session and retain the ARM64 CI regression. See `dev-docs/TAURI_MACOS_STARTUP.md`; do not disable window persistence or change the application chrome as a workaround. + - Verified shared npm OpenCode installations at 2.0.15+ delegate version changes to native `upgrade` through `opencode-update/native-upgrade.ts`, with bundled npm scoped to the verified prefix. Native Windows image retention replaces the write preflight only on that path; first install, older migration and same-version repair retain direct npm/preflight. Never retry a failed native mutation via npm or restart the daemon implicitly. Validate with the isolated `scripts/test-opencode-upgrade-native.mjs` fixture. - Git is a full-functionality prerequisite, with directory-only degraded conversations when the backend cannot find Git. Only the explicitly opened physical folder is session authority in that mode; never infer sibling worktrees from native project IDs. Keep ancestor/descendant mutation identities covered by the deletion fence across Git availability changes. Inform agents through the owned native `codenomad.git-availability` instruction before prompts/custom commands, remove stale context after recovery, and keep this advisory separate from fail-closed environment synchronization. No blocking Git setup UI. Validate with `scripts/test-git-degraded-native.mjs` using an isolated CLI/database and provider. diff --git a/README.md b/README.md index 74b46229e..422dc6014 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ npx @neuralnomads/codenomad-dev --password "your-password" --launch ## OpenCode V2 and upgrading from 0.19.x -CodeNomad **0.20.0 requires OpenCode V2**: **2.0.7 minimum**, with **2.0.18 recommended and qualified for this release**. OpenCode V1 and the former V2 beta protocol are not supported. The current requirements are maintained in [`runtime-support.ts`](packages/server/src/opencode/runtime-support.ts). +CodeNomad **0.20.0 requires OpenCode V2**: **2.0.7 minimum**, with **2.0.21 recommended and qualified for this release**. OpenCode V1 and the former V2 beta protocol are not supported. The current requirements are maintained in [`runtime-support.ts`](packages/server/src/opencode/runtime-support.ts). - **One shared OpenCode service** owns sessions and messages. Closing a CodeNomad tab, window, or backend leaves that service running. Desktop tabs, drafts, and layout are restored separately for each window. - **Use an existing CLI or install from CodeNomad.** Executables on `PATH` are preferred; the setup screen also supports a selected executable and a shared user npm installation. The former private CodeNomad OpenCode installation is no longer used. @@ -147,7 +147,7 @@ Add SideCar as: ## Requirements -- **[OpenCode V2](https://opencode.ai/v2/docs/)** — 2.0.7 minimum; 2.0.18 recommended for CodeNomad 0.20.0. Install/select it through desktop setup or provide a compatible CLI on the server host. +- **[OpenCode V2](https://opencode.ai/v2/docs/)** — 2.0.7 minimum; 2.0.21 recommended for CodeNomad 0.20.0. Install/select it through desktop setup or provide a compatible CLI on the server host. - **[Git](https://git-scm.com/downloads)** — a prerequisite for full functionality (repository identity, Git operations and worktrees). Git must be executable in the CodeNomad backend's `PATH`; it is not bundled with the desktop installers. - **Node.js and npm** — required for the standalone npm server and source builds, bundled with desktop releases. Use **Node.js 24 LTS**; development and release builds use the version in [`.node-version`](.node-version) (currently 24.20.0). diff --git a/dev-docs/BROWSER_DEVICE_EMULATION.md b/dev-docs/BROWSER_DEVICE_EMULATION.md new file mode 100644 index 000000000..2b7d259ca --- /dev/null +++ b/dev-docs/BROWSER_DEVICE_EMULATION.md @@ -0,0 +1,79 @@ +# Browser preview device emulation + +The viewport menu retains its original six fixed templates: responsive, +desktop 1440 × 900, tablet 768 × 1024, tablet landscape 1024 × 768, +mobile 390 × 844 and mobile landscape 844 × 390 CSS px. There is no separate +rotation button or size catalogue. Native mobile templates automatically apply +Android / Chromium touch, mobile identity and density settings. +Device profiles set CSS viewport, screen orientation, DPR 2.75, touch media and +events, Android user agent and mobile client hints. Switching between profiles +reloads the preview because applications can cache device detection at startup. +Returning to any dimension preset clears all overrides and reloads again. + +Electron and Windows Tauri apply overrides to the owned native preview only. +The sandboxed iframe fallback changes dimensions only, with a translated +tooltip explaining that emulation requires the native app. Neither application privileges nor a +general CDP bridge are exposed to preview pages. Tauri's synchronous WebView2 +callbacks are awaited off the UI thread. Electron retains its debugger attachment +while emulation is active so accessibility automation does not reset overrides. + +Canonical profile data lives in `packages/ui/src/lib/native/browser-emulation.json`. +Native adapters use the installed Chromium version for the simulated user agent. +These generic profiles do not emulate Gecko/Waterfox, Safari, mobile OS text settings, +keyboard occlusion or browser/system bars. They are a reproducible Android/Chromium +layout reference, not a pixel-identical representation of every phone. + +The composer keeps selectors and actions on one row at every width, truncating +labels rather than wrapping. Dense toolbar/footer touch targets use 32 px; timeline +markers use 24 px height. Below 460 CSS px of conversation width, the worktree +selector becomes a 32 px arrow-only cell while retaining its accessible label. +The timeline is hidden below 420 CSS px of conversation +width on every platform, independently of header density, status controls or +viewport height. The saved visibility preference is preserved. Conversation-header +actions, including filters, are forced into the overflow menu at the same 420 px +breakpoint, or earlier if their measured layout needs it. Docked side panels reserve +390 CSS px for the conversation before becoming overlays. + +Main local and remote desktop windows use a 390 × 600 CSS px content minimum. +Native logical minimums scale with application zoom (312 px wide at 80%, 390 at +100%, 488 at 125%, 585 at 150%), without multiplying monitor DPI twice. Restoring +a local window uses its saved zoom, including widths below 390 native logical px. +Electron seeds those constraints before the first navigation rather than reading +the renderer's transient 100% zoom. Local windows retain their shared authentication +session: host-scoped zoom reconciles each affected window's minimum and saved zoom, +including subsequent reloads. Remote session partitions remain independent. +Zooming in grows an undersized normal window; zooming out relaxes constraints but +does not shrink a larger window. Extreme zoom minimums are capped to the monitor's +work area. Preferences and browser preview guests keep their separate dimensions. +Tauri defers native minimum setters while maximized, fullscreen or minimized to +preserve window state and normal placement. A per-window registration coalesces +move/resize/DPI updates off native callbacks, with a 500 ms read-only environment +fallback for state/work-area changes missing from the pinned runtime's events. +Unchanged minimums perform no native writes; comparisons use physical pixels to +avoid resize loops at fractional DPI. Restore positioning precedes registration. +The isolated Windows regression runs with `cargo run --locked --example +window_constraints_windows` from `packages/tauri-app/src-tauri`; `-- --baseline` +demonstrates the original unmaximize bug. It uses temporary WebView2 profiles and +no backend or shared daemon. Multi-monitor/DPI calculations also have Rust tests; +the native run only covers monitors actually attached to the test host. +On macOS/Linux, `window_zoom.js` routes keyboard and Ctrl-wheel input through the +relative-only `owned_webview_zoom` command rather than Tauri's generic zoom command. +The host validates the registered primary webview and its origin, then updates +native zoom, bookkeeping and constraints together. Windows keeps native input. +Preview children receive neither this input script nor application capability +authority. The shipped and pinned-runtime scripts are exercised by +`node --test packages/tauri-app/tests/window-zoom-script.test.mjs` after Cargo has +downloaded the locked Tauri source; CI runs this on all three desktop platforms. +Tests cover 360, 390 and 430 px widths +plus 320 px, landscape, mouse/touch media, draft preservation, native profile/reset +behavior and the real viewport menu's native/fallback boundaries. + +Composer sizing also follows the visible conversation height (including the +visual viewport when a software keyboard opens). The minimum is 8% of that +height, with a 44 px floor. Width and draft length do not change the limits. +Long drafts scroll inside the field. Manual sizing is saved as a proportion +of the available height, bounded to 8–60%: the field itself scales when the +window or visual viewport changes. The 44 px floor never overwrites that +proportion. Legacy pixel heights convert on the first valid measurement. +Pointer and keyboard +resizing share the same minimum and maximum. diff --git a/dev-docs/CACHE_REFRESH_CONVENTIONS.md b/dev-docs/CACHE_REFRESH_CONVENTIONS.md index 4018b60ce..16b242332 100644 --- a/dev-docs/CACHE_REFRESH_CONVENTIONS.md +++ b/dev-docs/CACHE_REFRESH_CONVENTIONS.md @@ -26,6 +26,7 @@ where their authority, lifetime or runtime differs. | Session outline | Structural indexes independent of transcript and excerpts; up to 16 memory snapshots / 200k entries (one larger active index allowed). Restoration persists up to 16 indexes / 200k entries / 16 MiB in optional content-addressed chunks. Saved geometry displays before full checkpoint revalidation; unchanged ranges do not resend entries. The last range grows to 512 rows before another is added. Unchanged session returns make no read; native message changes revalidate checkpoints. Destructive mutation, generation, location/project and undo boundary fence reuse. Paused scans retain cursors. | `packages/ui/src/stores/session-outline.ts`, `session-outline-persistence.ts`, `client-state-outline-partitions.ts` | | Timeline previews | At most 512 bounded Markdown excerpts. Visible-nearby batches of 12, hovered ID first; obsolete demand cancels. Thirty-second age prompts on-demand revalidation only. Generation, mutation and undo fence reuse. No transcript-window loads or geometry changes. | `packages/ui/src/stores/timeline-previews.ts` | | Providers/models | Retained signals; shared in-flight catalogue load; dirty-bit trailing refresh; instance, location and request-generation checks. No completed-result TTL inside `fetchProviders` itself. | `packages/ui/src/stores/session-api.ts` | +| Provider Usage | Panel-local snapshot; same-location `provider.updated` revalidates in place with one in-flight read and coalesced trailing demand. Window-label keys preserve quota DOM across responses. Credential/integration/config events, reconnects and source changes clear the snapshot immediately and fence old responses/trailing demand. Failures revoke quotas rather than retain a possibly different account. Hidden panels do not read; no global browser quota cache. | `components/session/provider-usage-panel.tsx`, `provider-usage-state.ts` | | Session model selection | Native selection events invalidate rather than overwrite the displayed selection, because SSE echoes can lag successful HTTP writes. Coalesced authoritative reads share the session admission queue with prompts and explicit selections; client/generation and captured-selection checks fence late responses. Catalog merges preserve pending optimistic choices; successful writes publish a fresh model object so pre-confirmation snapshots lose authority. Read failures retain display state until the next event or reconnect refresh. | `stores/session-model-reconciliation.ts`, `stores/session-action-queue.ts`, `stores/session-generation-recovery.ts` | | OpenCode setup | Shared in-flight status read plus coalesced trailing refresh for focus/visibility/reconnect demand. Executable changes and explicit actions fence old responses. Failed reads retain version/path diagnostics but revoke the connected claim and stale reload/restart controls; Continue checks again before retrying the pending workspace. Activation requires a successful pre-action status read. | UI `stores/opencode-setup.ts`, `components/settings/opencode-setup-panel.tsx` | | Git changes | Filesystem events debounce for 100 ms; one passive refresh plus a pending follow-up; hidden project/panel marked stale. Queued reads cancel on deactivation/disposal and request versions fence late status/diff results. Reads share the secondary HTTP budget. Server shares concurrent status requests, not completed results, and runs status/diff process creation in the Git worker. | `useGitChanges.ts`, `filesystem-events.ts`, server `workspaces/git-status.ts` | diff --git a/dev-docs/FILES_PANEL.md b/dev-docs/FILES_PANEL.md new file mode 100644 index 000000000..0293ab5ea --- /dev/null +++ b/dev-docs/FILES_PANEL.md @@ -0,0 +1,104 @@ +# Files panel + +The right panel has one Files entry with three compact modes: + +- **Workspace** (initial default): lazy directory tree for the browsed worktree. +- **Changes**: indexed and working-tree changes, including new/deleted files. +- **Commits**: paginated history, commit message and changed-file list. + +The last selected mode and existing diff/wrap preferences use client layout +storage. Migration merges the old Git and Files tab positions and visibility +once, retaining access when either entry was previously visible. Each mode +retains its mounted navigation while switching between them. Tree snapshots are +scoped to the physical worktree directory. All three modes are cache-first: +listings, git status and history serve cached data and revalidate lazily on +filesystem invalidation, mode activation (when absent or marked stale) or +explicit refresh. Reopening a folder re-reads its visible subtree only when the +filesystem changed since it was loaded; collapsed folders are never re-read on +expand. Workspace rows expose a hover/focus `…` menu (folder: open, terminal, +copy path; file: open, reveal, copy path). Changes rows carry an explicit +per-file stage/unstage button and accept drag-and-drop between the staged and +unstaged sections. File row clicks select without opening the reader. Workspace, +Changes and commit-file rows share `FileRowActions`: a pinned eye toggles the +central preview with the shared `icon-toggle` active state. Secondary actions +stay inline while the measured whole row fits, falling back to the shared menu +only on overflow; an open menu survives resizing until dismissal. Hover uses +`control-hover.css`'s shared surface overlay and selection covers the whole row. +Change counts are muted; additions use success green and deletions error red. + +Changes retains two independently collapsible sections, initially expanded: +Staged Changes and Changes. Git actions and the compact commit-message field +live at the top of Staged Changes, before its file rows, without an additional +Actions Git disclosure at the bottom. Collapsing either section preserves the +commit draft and selection, causes no status read, and leaves its header as a +drop target for staging/unstaging even when the file list is hidden. Mode switches +and status refreshes preserve both sections' disclosure state. + +## Central reader + +`files-preview-view.tsx` selects the shared diff reader or the editable workspace +reader (source, rendered Markdown, raster/vector image, or binary fallback). +The composer stays mounted below it. Selections are bound to the originating +project/session and cleared on session/worktree changes. Opening the browser +preview replaces the file reader; opening a file returns the browser to chat mode. +Cancelling or hiding a view fences pending reads. Workspace/local-diff readers +refresh on filesystem invalidation, while commit contents remain immutable. + +Git image previews retain base64 bytes for the actual compared revisions: +HEAD/index for staged changes, index/worktree for unstaged changes, and the +parent/commit blobs for history. `git-image-preview.ts` uses bounded worker reads +and the existing bounded filesystem reader; no checkout occurs and historical +images never fall back to the current worktree. `GitImagePreview` shows before +and after images (including one-sided additions/deletions); other binary files +retain their unavailable-preview fallback. + +Workspace source retains Monaco editing, Save and Ctrl/Cmd+S in the central view; +Markdown's source mode is editable too. Images, binary content and historical +diffs remain read-only. The former sidebar layout is retired, not file editing. +Dirty drafts are retained in renderer memory per instance/directory/path when +closing or changing readers, and filesystem invalidation never replaces them. +Saving rereads the bounded authoritative file and asks before overwriting external +changes; read/write errors preserve the draft. Explicit refresh asks before +discarding edits. Monaco's tokenizers are packaged locally for offline highlighting. + +Clicked readers have a reserved one-request lane beside the two background scan +slots. Start the local Monaco load alongside the content read; preview Git work +uses the worker's foreground queue. Commit metadata and parent IDs share one Git +process. These changes preserve ownership checks and cancellation fencing. + +`lib/monaco/theme.ts` resolves the selected palette's CSS tokens into Monaco hex +colors, including source tokens, editor surfaces and added/removed-line tints. +Its observer follows live appearance/custom-palette changes and is disposed with +the viewer. Read-only previews disable cross-model occurrence highlighting to +avoid Monaco 0.52's leaked cancellation on fast model changes; text selection and +insertion into the composer remain available. + +## Backend boundaries + +- `/api/workspaces/:id/files/preview` accepts a relative `path` and the browsed + `directory`, validates it through the manager's existing worktree ownership, + and uses the bounded filesystem content reader (5 MiB, base64). This also works + for the explicitly opened directory in Git-degraded mode. +- `/api/workspaces/:id/worktrees/:slug/git-history` returns up to 50 commits plus + a continuation flag. Later pages retain their original HEAD hash. +- `PUT /api/workspaces/:id/files/content` accepts the exact browsed `directory` + alongside the relative path, checks current ownership and the deletion fence, + and uses the existing filesystem writer. It never redirects a linked-worktree + edit to the root checkout. +- `/git-history/:commit` returns the message and files; an optional `path` returns + before/after text against the first parent (empty for a root commit). +- Commit IDs are full SHA-1/SHA-256 hashes. Files must belong to the commit's + authoritative change list. Git commands run in the existing worker with a + timeout and output bound. Renames use their original path; gitlinks show their + object IDs rather than reading another repository. Binary diffs use a fallback. + +## Validation + +- `server/routes/files-history.test.ts`: real temporary root/linked repositories, + route ownership, file bounds, history/diff and directory-only reading. +- `workspaces/git-history.test.ts`: root/merge/rename/delete/binary/gitlink cases + and snapshot pagination across a new HEAD. +- `tests/browser/git-history.test.ts`: the real panel/readers and SessionView, + draft preservation, late-read/session fencing, filesystem refresh and keyboard + tree navigation. The design fixture remains available through + `node scripts/preview-git-history.mjs` with synthetic data only. diff --git a/dev-docs/OPENCODE_V2_COMPATIBILITY.md b/dev-docs/OPENCODE_V2_COMPATIBILITY.md index 219d015cd..0cc2cac5b 100644 --- a/dev-docs/OPENCODE_V2_COMPATIBILITY.md +++ b/dev-docs/OPENCODE_V2_COMPATIBILITY.md @@ -15,9 +15,8 @@ runtimes. Keep detailed results in the change's PR and CI logs, not in per-versi reports. Update this reference in place. PR #696's corrected technical minimum is **2.0.7**, when native step-start events -gain the `data.started` field consumed by the current Solid reducer. Server/UI -client and bundled-plugin dependencies pin **2.0.18**, which is also the -recommended release-tested target, not the minimum. Unlisted versions, +gain the `data.started` field consumed by the current Solid reducer. **2.0.21** +is the recommended release-tested target, not the minimum. Unlisted versions, including prereleases/custom labels/future majors, undergo authenticated contract recognition rather than being refused solely for their label. Missing canonical APIs or session environment support produce a concrete incompatibility reason. @@ -29,6 +28,32 @@ The isolated 2.0.3→2.0.11 seed confirms native workspace-selector collapse whi preserving session IDs and complete history. See the [transition register](OPENCODE_V2_POST_BETA.md) for actual coverage and release gates. +### Native Codex subscription usage + +Codex/OpenAI quota reads require the **2.0.20** native `credential.list` API. +This is a feature-local dependency, not a new global minimum: a missing endpoint, +expired token or unsupported connection yields unavailable usage. The server +identifies a credential-endpoint HTTP 404 with a sanitized, feature-local upgrade +reason; the panel explains the required service version without blocking the app. +The server +resolves the owned session's native directory and provider integration, then uses +only that integration's first connection and its matching active ChatGPT OAuth +credential (`chatgpt-browser` or `chatgpt-headless`). It never reads host legacy +`auth.json`, Codex CLI credentials or SQLite, refreshes OAuth independently, or +substitutes another saved account. WSL uses the selected daemon's credentials. + +Quota snapshots are scoped to the acquired native connection, instance, session, +directory, provider and selected credential identity. Warm and pending results +revalidate native selection before publication. Credentials remain server-only; +browser credential export and generic RPC are still blocked by the proxy. +Isolated HTTP/generated-client and rendered Solid fixtures cover these boundaries. +`node scripts/test-provider-usage-native.mjs ` additionally +exercises the production quota adapter against a fresh native daemon/database with +synthetic OAuth/key credentials and mocked quota HTTP only. It passed on Windows +with 2.0.20 and 2.0.21: active-account selection and `metadata.accountID`, warm-cache revocation +after switching to a key, and recovery despite a stale legacy file. This is not a +real WSL or live ChatGPT quota test; no real credentials or shared daemon are used. + ### Side questions (`/btw`) The composer-owned command calls native `session.generate({ sessionID, prompt })` @@ -158,7 +183,7 @@ independent gatekeeper reports zero actionable findings. Detailed native suite completion and cross-platform qualification belong in the PR/CI; do not treat unchanged wire types or a passing rerun as proof of untested behavior. -### Current stable target: 2.0.18 +### Previous stable target: 2.0.18 Server/UI client, bundled plugins and recommendation advance together to **2.0.18**; the demonstrated minimum remains **2.0.7**. The published 2.0.16→2.0.18 client @@ -191,6 +216,96 @@ proxy checks passed. The earlier 2.0.16 dependency baseline passes against runti previously recorded above. Both historical migrations (2.0.3 and beta-19271) to 2.0.18 pass; the native npm installation leaves the 2.0.15 daemon and PID intact. +### Previous stable target: 2.0.19 + +The recommendation and synchronized server/UI client and plugin pins advance to +**2.0.19**; the timestamp-based minimum remains **2.0.7**. The authenticated native +2.0.19 OpenAPI has the same 115 paths and component schemas as native 2.0.18. +All published client, plugin, protocol and schema declarations are byte-identical +(30, 60, 36 and 102 declaration files respectively). No new API adapter or proxy +route follows from this release. + +Upstream refactors compaction and adjusts output limits to the context window +(with a 256k output cap), including bounded shrinking after provider overflow +rejections. It also improves provider prompt-cache reuse and session affinity, +one-shot generation attribution, error classification and media handling. Native +Shell tools add `AGENT=1`, `OPENCODE=1`, default `AI_AGENT=opencode` and the current +`OPENCODE_SESSION_ID`; these native tool conventions are distinct from CodeNomad's +per-send session environment snapshot. Qualification must exercise compaction, +history persistence, side questions and environment propagation, not infer them +from the unchanged HTTP schema alone. Detailed outcomes belong in the PR/CI. + +The seven native suites pass on both 2.0.7 and 2.0.19 with the new pins, including +pruning/compaction, history navigation, proxy/relay ownership, automation, per-send +environment, forks, side questions, blank-session cleanup and Git-degraded recovery. +Both historical migrations to 2.0.19 and the isolated native npm upgrade pass; +the latter preserves the running 2.0.15 daemon's version and PID. + +Ordinary native/UI runs stopped at Vite import with shell exit 116, reproduced +with a 2.0.18 runtime control and the previous dependency pins. Per-import traces +now isolate that phase more precisely. These observations do not establish the +same cause as the earlier heap-corruption exit, or resolve the fixture failure. +The full native/UI acceptance passes when a diagnostic launcher preloads Vite +before the native fixture. That run changes import order, not the assertions or +runtime; it provides acceptance coverage without proving the ordinary launcher +is reliable. Keep both outcomes visible in the PR. + +### Previous stable target: 2.0.20 + +The recommendation and synchronized server/UI client and plugin pins advance to +**2.0.20**; the demonstrated timestamp-based minimum remains **2.0.7**. Authenticated +native OpenAPI adds `GET`/`POST /api/credential` (116 paths, with the previous 115 +path definitions unchanged). Credential export/creation remains outside the +workspace proxy; a regression verifies rejection before any upstream request. +Existing components gain optional `Session.StructuredError.response.body` and +connection `status` (`needs_auth`, message and optional URL); the other additions +describe credentials. Published declarations propagate these additions and expose +the plugin's `integration.connection.status` reporting method. No consumed API +requires a production adapter or higher runtime minimum. The UI normalizer retains +the optional response body while keeping the existing display message contract. + +Upstream also improves provider error messages, Bedrock/Mistral reasoning blocks, +prompt caching, ChatGPT token-sharing authentication and database-file permissions. +The new CLI `service disabled` setting controls implicit CLI connections; isolated +validation with CodeNomad's real lifecycle confirms explicit `service start` and +authenticated discovery still work with `disabled=true`, without clearing it. +CodeNomad never writes native service configuration to accommodate the setting. + +Seven native suites pass on both 2.0.7 and 2.0.20 with the new pins. Native/UI +acceptance also passes through the ordinary launcher, without Vite preloading. +This passing run does not identify or fix the intermittent Windows import failure +documented above. Historical migrations, standalone plugin acceptance and isolated +npm upgrade pass; upgrade preserves the running 2.0.15 daemon's version and PID. +Detailed test counts, environment and final review/CI outcomes belong in the PR. + +### Current stable target: 2.0.21 + +The recommendation and synchronized server/UI client and plugin pins advance to +**2.0.21**; the global minimum remains **2.0.7**, and Codex Usage's feature-local +credential-list requirement remains **2.0.20**. Authenticated native OpenAPI keeps +116 paths: only form cancellation changes, with an optional `message` query and +optional `message` in cancelled `Form.State`. Existing cancellation without a +message remains supported. A synthetic native fixture verifies session/global +cancellation both ways, including exact preservation of URL-sensitive text. + +Published declarations change in two client files, one protocol file and one +schema file; all 60 plugin declaration files are unchanged. No new consumed API +or required field justifies an adapter or higher minimum. Plugin optional OpenTUI +peer floors advance to 0.5.14; CodeNomad does not install these terminal peers. +The release also fixes provider context-overflow classification, passthrough and +prompt-cache behavior, avoids reinjecting ancestor instructions, adds namespaced +session identity headers to provider requests, and makes native browser tools +conditional on desktop attachment. CodeNomad's own automation acceptance passes. + +Seven native suites pass on both 2.0.7 and 2.0.21 with the new pins. The native +Codex Usage fixture also passes on 2.0.21 with synthetic credentials and mocked +quota HTTP. Ordinary-launcher native/UI acceptance, standalone plugin acceptance, +historical migrations and isolated npm upgrade pass. Upgrade leaves the running +2.0.15 daemon and PID unchanged. The full server run has one Windows Git-fixture +cleanup `EPERM`; its unchanged focused rerun passes. Retain this failure and the +historical intermittent Vite/import limitation rather than claiming either fixed. +Detailed counts, review and CI outcomes belong in the PR. + The audit and implementation record below is historical context, not a runtime qualification matrix to maintain. diff --git a/dev-docs/TAURI_MACOS_STARTUP.md b/dev-docs/TAURI_MACOS_STARTUP.md new file mode 100644 index 000000000..6491dbf78 --- /dev/null +++ b/dev-docs/TAURI_MACOS_STARTUP.md @@ -0,0 +1,83 @@ +# macOS startup: borderless maximize getter event feedback + +## Captured failure + +The published development release `v0.20.0-dev-20261001-77692a73` reproduces a +startup hang in a graphical macOS 15.8 (24H23) Intel Hyper-V guest. Its main +thread stays busy in this chain: + +`setup_local_window` geometry event → `capture_window_in_memory` → +`read_window_geometry` → `Window::is_maximized` → Tao `is_zoomed` → +`set_style_mask_sync` → AppKit `-[NSWindow setStyleMask:]`. + +The process remains near one full CPU core. The backend starts independently, +but the native event loop cannot complete loading-screen navigation. The hang +also occurs in a fresh scoped CodeNomad profile with an intentionally missing +OpenCode executable, before OpenCode can connect. The previously installed +0.20.1 application shows the same sampled path. + +This is not the writer-lock/thread explosion fixed for #676. That fix remains +necessary: native getters still run outside client-state locks, and geometry +publication still uses the single debounced worker. + +## Narrow fix and provenance + +Backport the exact macOS source hunk from Tao upstream commit +`2fb512315899973e2c64d00c0e34ed382d93c893` (tao#1182) into the existing +`packages/tauri-app/vendor/tao-0.34.6/` override. The old getter temporarily +changed the borderless window's style mask twice, generating new geometry +events whose capture called the same getter again. The new getter reads the +window and screen frames instead. Titled/resizable windows and missing-screen +fallback retain AppKit's native `isZoomed` query. + +No Tauri dependency/API upgrade, application chrome change, persistence +workaround or shared OpenCode lifecycle change is involved. The Windows input +backport remains intact; Electron uses its own native read-only getter and +requires no production change. + +## Validation + +- `node scripts/test-tauri-macos-window.mjs --baseline` requires a graphical + macOS session. It builds the original published Tao as a negative control in + a temporary workspace and the backport in the real workspace. Both use the + same native fixture, with a bounded watchdog and no application/backend data. + The baseline has its own target directory; the patched artifact is warmed + before it and rechecked afterwards. CI runs the command twice consecutively. + Only a normal getter/read-event assertion or a watchdog in that exact phase + with at least 100 capture callbacks counts as feedback reproduction. Setup, + later-phase and low-evidence timeouts fail the negative control. +- The fixture checks read-generated move/resize events, getters inside geometry + capture, normal bounds, maximize/restore, fixed-size and decorated windows. +- The PR macOS ARM64 job runs the regression in addition to the Tauri crate + tests. Packaging success alone does not qualify this event-loop behavior. +- Run the existing Windows input regression with `--baseline` and the Electron + window/persistence tests to protect cross-host behavior. + +Local non-macOS checks passed: 188 Tauri crate tests on Windows, all six +Windows input cases with their negative controls, and 44 Electron +window/restore/navigation/startup tests. The upstream source patch also passes +`git apply --check --reverse` against the vendored file. + +The native macOS fixture passed in the Intel 15.8 VM: the original published +crate reproduced geometry-event feedback, and the backport passed all +read-only getter and maximize/restore checks. The release Tauri binary was +rebuilt from the `fb2ae42a` development source plus the backport, using the +published release's bundled Node/server/UI resources in a separate test tree. + +The rebuilt package-mode application reaches the interface in the VM (confirmed +in the Hyper-V console by the tester), with the native process at 0.0% CPU at +idle. A second native launch opens a separate window and a synthetic project +using OpenCode 2.0.21 in a separate HOME/XDG/database environment. After a native +AppleScript Quit and relaunch, two retained V3 window records are restored and +the isolated service remains on the same endpoint. No shared daemon or existing +application installation is modified. Guest `screencapture` returns white +frames in this Hyper-V display setup, so those captures are not visual evidence. + +The available local VM is Intel, not Apple Silicon. Direct local ARM64 +reproduction is unavailable; keep that distinction when reporting validation. + +## Override removal + +Remove the workspace override only when the supported resolved Tao release +includes both tao#1182 and tao#1215 and both native negative-control regressions +pass. Do not replace registry-cache sources or patch them at build time. diff --git a/dev-docs/UI_MOTION_PROFILING.md b/dev-docs/UI_MOTION_PROFILING.md new file mode 100644 index 000000000..7f6f5fb4f --- /dev/null +++ b/dev-docs/UI_MOTION_PROFILING.md @@ -0,0 +1,57 @@ +# UI motion profiling (#804) + +Connected MCP servers describe a stable state, not active work. Their status +dots stay green without pulsing. Only a pending connect/disconnect retains the +warning pulse and spinner; success and failure both stop that feedback. This is +shared UI behavior for web, Electron and Tauri. + +The existing global `prefers-reduced-motion: reduce` rule limits animations to +one near-instant iteration and shortens transitions. The MCP fix does not add a +second motion policy or alter refresh/cache semantics. + +## Regression and bounded profiling + +```powershell +node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/mcp-motion.test.ts packages/ui/tests/browser/status-panel.test.ts +node scripts/profile-ui-motion.mjs eb1bf48e +``` + +The optional Git revision substitutes only the historical MCP component. The +remaining source and installed frontend dependencies are identical. The script +writes Chromium traces, screenshots and `results.json` under a temporary +`opencode/issue804-motion-*` directory. No native daemon or user data is used. + +Fixtures use the real StatusTab, SUID switches, full stylesheet and SessionView +with the native event dispatcher. MCP calls and session HTTP are simulated. The +session fixture seeds 620 short history messages, then samples idle, token-free +single/grouped reasoning, 24 text deltas with a 50 ms delay between deliveries, +and completion. Both motion settings use a 150% device scale. + +Observed in the September 30 isolated Chromium run: + +| State | Infinite animations | Layout activity in ~1.2 s sample | +| --- | --- | --- | +| Eight connected MCPs, before fix | 8 | 0 layouts (opacity animation) | +| Eight connected MCPs, after fix | 0 | 0 layouts | +| Collapsed MCP section | 0 | 0 layouts | +| Single reasoning step, no deltas | 0 | 0 layouts | +| Grouped reasoning, no deltas | 1 spinner | 0 layouts | +| Grouped reasoning, reduced motion | 0 | 0 layouts | +| Session complete | 0 | 0 layouts | + +Text streaming still generates rendering work with reduced motion: 52 layouts +were recorded for 24 deliveries in both settings (~1.7 s including delivery +overhead). The mounted transcript remained at 16 rows, rather than 620. These +observations do not establish a full-transcript redraw on every animation frame. + +## Limits / follow-up + +This is a **headless Chromium rendering sample**, not a WebView2/NVIDIA GPU +measurement. Compositor-only animation can consume GPU without producing main +thread layout/paint counters. No GPU percentage reduction is claimed. + +The session fixture omits application/sidebar chrome and does not supply the +full-history timeline RPC. Long reasoning, large tool results, remote HTTP +bursts and full-app freezes still require a representative native recording. +The generation symptom of #804 and the remote Electron freeze in #805 remain +separate, unconfirmed problems; this change must not close either as fully fixed. diff --git a/package-lock.json b/package-lock.json index e602f23eb..a549b0e90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4843,14 +4843,14 @@ } }, "node_modules/@opencode/ai": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.18.tgz", - "integrity": "sha512-zzd01WCh4rdvA29DwUVhG3n8rnn4y8jAobBE34ot5ueRmljO8AZ61/fq0+89tcm2OtEkGoMiAoBALJqd2LaWtQ==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.21.tgz", + "integrity": "sha512-eB4M0O44VoX075Kv+vIVxIpiKSrfjSXpNaeBSjtPgZdhZCjKN5CxjJXtyPbhrDxvmDf0PxLcvjGNKQBRMkZ0fw==", "dev": true, "license": "MIT", "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", - "@opencode/schema": "2.0.18", + "@opencode/schema": "2.0.21", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", @@ -4859,13 +4859,13 @@ } }, "node_modules/@opencode/client": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.18.tgz", - "integrity": "sha512-N74FNdXkTTC1Re5T+PNxH6fsVf1tZiV3kESu3UWL/545oAkBRXoiikMmDszuuHZ4AxGwtr9NiQjKrHiMdnLVYQ==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.21.tgz", + "integrity": "sha512-cX9sj9qpaTlkVfdaeLCFeV43+1mcSHVhvgjkVLCYyfQJj0MmBGgx/x9AM/GHVZDEX6T2mUljn0Qh5XhFM9CgrQ==", "license": "MIT", "dependencies": { - "@opencode/protocol": "2.0.18", - "@opencode/schema": "2.0.18" + "@opencode/protocol": "2.0.21", + "@opencode/schema": "2.0.21" }, "peerDependencies": { "effect": "4.0.0-rc.112", @@ -4881,26 +4881,26 @@ } }, "node_modules/@opencode/plugin": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.18.tgz", - "integrity": "sha512-jokYYdAGd/L+WY0GbEE1Bh1/F8tG98UaTlvvyBriOuVDyeadHFdTL9QoXx+u7pAWwJRTEMOESxxifS/blGPjyg==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.21.tgz", + "integrity": "sha512-ZLagbklGyYPg77GvK//ir3LaNXBsZQENU8APCnaT4elsBk7YzoVt8stR6+b4wH7Sfadw76cWSaNiWgDo2QOSHA==", "dev": true, "license": "MIT", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@opencode/ai": "2.0.18", - "@opencode/client": "2.0.18", - "@opencode/protocol": "2.0.18", - "@opencode/schema": "2.0.18", - "@opencode/util": "2.0.18", + "@opencode/ai": "2.0.21", + "@opencode/client": "2.0.21", + "@opencode/protocol": "2.0.21", + "@opencode/schema": "2.0.21", + "@opencode/util": "2.0.21", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { - "@opencode/theme": "2.0.18", - "@opentui/core": ">=0.5.12", - "@opentui/solid": ">=0.5.12", + "@opencode/theme": "2.0.21", + "@opentui/core": ">=0.5.14", + "@opentui/solid": ">=0.5.14", "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { @@ -4929,19 +4929,19 @@ } }, "node_modules/@opencode/protocol": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.18.tgz", - "integrity": "sha512-IhzzlCz/hAlFvBLWQ+kDceX0/CkYOrjVyTPwYJYvWwKze5AHzVxxDYxkhCqLJj0hn/wtdNL10nhjVothuHcaBQ==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.21.tgz", + "integrity": "sha512-KvcpCPpIXPMkJaffUFwLxUAZwf6SiNf/8qFW/ZR7LEHFBcnmzyl7hPk5QthAHmnTRoR+dU2U/WMakj06ePRPyg==", "license": "MIT", "dependencies": { - "@opencode/schema": "2.0.18", + "@opencode/schema": "2.0.21", "effect": "4.0.0-rc.112" } }, "node_modules/@opencode/schema": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.18.tgz", - "integrity": "sha512-f+lp6f+kxhygdeSxjqkiu6vk9t0onH/6OVBghSNyo9ZUUFP9BU/2JKthMgy9kHGyFu2vp0lg01H9umXn76u52A==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.21.tgz", + "integrity": "sha512-HMIU9sCNOvbMWQeslxbTtCLfvhMyar40+VHkZq9hNjXTaPWJrGxSzbuHJQhKzeOeDaiP7t1zllUDHTxTA/5SOw==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", @@ -4949,9 +4949,9 @@ } }, "node_modules/@opencode/util": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.18.tgz", - "integrity": "sha512-PmrOwB3k9nlubvM9ZIj08uann/SpPAfBt6fPI4OiRCnNKBNkEbU4d3CZIFL3QtxTCn1OPzF/AfRDQUuE4Tf+Mg==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.21.tgz", + "integrity": "sha512-wQaa4Q19zbK+B76Pzf2NV3eE8orwWw7IDlBi/Y4WpUaHwaaF+fCQGQxkByyOeWmSy38IQEX93c8RyLPB+WWjog==", "dev": true, "license": "MIT", "dependencies": { @@ -6995,9 +6995,9 @@ "optional": true }, "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", "dev": true, "license": "MIT", "dependencies": { @@ -18680,7 +18680,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode/client": "2.0.18", + "@opencode/client": "2.0.21", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", @@ -18697,7 +18697,7 @@ "codenomad": "dist/bin.js" }, "devDependencies": { - "@opencode/plugin": "2.0.18", + "@opencode/plugin": "2.0.21", "@types/node-forge": "^1.3.14", "@types/yauzl": "^2.10.0", "cross-env": "^7.0.3", @@ -18737,7 +18737,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode/client": "2.0.18", + "@opencode/client": "2.0.21", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/electron-app/electron/main/browser-controller.test.ts b/packages/electron-app/electron/main/browser-controller.test.ts index 6eca61043..a23a0ec4f 100644 --- a/packages/electron-app/electron/main/browser-controller.test.ts +++ b/packages/electron-app/electron/main/browser-controller.test.ts @@ -25,6 +25,7 @@ function createGuest(options: GuestOptions) { isDestroyed: () => false, isLoading: () => options.loading ?? false, stop: () => undefined, + reload: () => undefined, getURL: () => url, loadURL: async (nextUrl: string) => { await options.loadURL?.(nextUrl) @@ -57,6 +58,87 @@ function createHarness(requestOpen: (sessionID: string, url: string, requestID: } describe("BrowserController", () => { + it("preserves mobile overrides when automation times out and fences late protocol work", async () => { + const harness = createHarness() + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + const methods: string[] = [] + let stall = true + const { guest, counts } = createGuest({ id: 96, owner: harness.owner, sendCommand: async method => { + methods.push(method) + if (method === "Browser.getVersion") return { product: "Chrome/140.0.0.0" } + if (method === "Accessibility.enable" && stall) await gate + return { nodes: [] } + } }) + harness.add(guest, "mobile-timeout") + await harness.controller.emulate(harness.owner, "mobile-timeout", "mobile") + await assert.rejects(harness.controller.execute("session", { action: "snapshot" }, Date.now() + 30), /timed out/) + assert.equal(guest.debugger.isAttached(), true, "a timeout must not clear persistent mobile overrides") + assert.deepEqual(counts(), { attachCount: 1, detachCount: 0 }) + release() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(methods.includes("Accessibility.getFullAXTree"), false, "expired continuations must not send protocol commands") + stall = false + await harness.controller.execute("session", { action: "snapshot" }) + assert.equal(guest.debugger.isAttached(), true) + await harness.controller.emulate(harness.owner, "mobile-timeout", "none") + assert.deepEqual(counts(), { attachCount: 1, detachCount: 1 }) + }) + + it("reapplies the same mobile profile after an external debugger detach", async () => { + const harness = createHarness() + let applications = 0 + const { guest } = createGuest({ id: 97, owner: harness.owner, sendCommand: async method => { + if (method === "Browser.getVersion") return { product: "Chrome/140.0.0.0" } + if (method === "Emulation.setDeviceMetricsOverride") applications++ + return {} + } }) + harness.add(guest, "mobile-detach") + await harness.controller.emulate(harness.owner, "mobile-detach", "mobile") + guest.debugger.detach() + await harness.controller.emulate(harness.owner, "mobile-detach", "mobile") + assert.equal(applications, 2) + assert.equal(guest.debugger.isAttached(), true) + }) + + it("rejects emulation from another owner and fences queued target replacement", async () => { + const harness = createHarness() + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + let entered!: () => void + const started = new Promise(resolve => { entered = resolve }) + const { guest } = createGuest({ id: 95, owner: harness.owner, sendCommand: async method => { + if (method === "Accessibility.getFullAXTree") { entered(); await gate; return { nodes: [] } } + return {} + } }) + harness.add(guest, "emulation") + await assert.rejects(harness.controller.emulate({ id: 2 } as WebContents, "emulation", "mobile"), /does not belong/) + const snapshot = harness.controller.execute("session", { action: "snapshot" }) + await started + const emulation = harness.controller.emulate(harness.owner, "emulation", "mobile") + harness.controller.unregister(harness.owner, "emulation") + release() + await snapshot + await assert.rejects(emulation, /target changed/) + }) + + it("cleans registrations after native window destruction without reading its getters", () => { + const harness = createHarness() + let destroyed = false + const window = new EventEmitter() + Object.defineProperty(window, "webContents", { get() { + if (destroyed) throw new TypeError("Object has been destroyed") + return harness.owner + } }) + harness.controller.observeOwner(window as unknown as BrowserWindow) + const { guest } = createGuest({ id: 30, owner: harness.owner }) + harness.add(guest, "closed-window") + assert.deepEqual(harness.controller.probe("session"), { available: true }) + destroyed = true + assert.doesNotThrow(() => window.emit("closed")) + assert.throws(() => harness.controller.probe("session"), /No visible local browser target/) + }) + it("serializes debugger commands for each guest", async () => { const harness = createHarness() let releaseTree!: () => void diff --git a/packages/electron-app/electron/main/browser-controller.ts b/packages/electron-app/electron/main/browser-controller.ts index 54d51b7f0..d07c7b980 100644 --- a/packages/electron-app/electron/main/browser-controller.ts +++ b/packages/electron-app/electron/main/browser-controller.ts @@ -2,6 +2,7 @@ import { createRequire } from "node:module" import { randomUUID } from "node:crypto" import type { BrowserWindow, WebContents } from "electron" import { isBrowserUrlAllowed } from "./browser-webview-security" +import { setBrowserEmulation } from "./browser-emulation" const electron = createRequire(import.meta.url)("electron") as typeof import("electron") @@ -106,6 +107,17 @@ export class BrowserController { this.registrations.set(input.registrationId, { ...input, owner, guest }) } + async emulate(owner: WebContents, registrationId: unknown, preset: unknown): Promise { + const registration = typeof registrationId === "string" ? this.registrations.get(registrationId) : undefined + if (!registration || registration.owner !== owner) throw new Error("Browser target does not belong to this window") + await this.enqueue(registration.guest, async () => { + if (this.registrations.get(registration.registrationId) !== registration || owner.isDestroyed() || registration.guest.isDestroyed()) { + throw new Error("Browser target changed before emulation") + } + await setBrowserEmulation(registration.guest, preset) + }) + } + unregister(owner: WebContents, registrationId: unknown): void { if (typeof registrationId !== "string") return const registration = this.registrations.get(registrationId) @@ -130,7 +142,14 @@ export class BrowserController { return true } - removeOwner(owner: WebContents): void { + observeOwner(window: BrowserWindow): void { + // BrowserWindow's native getters throw once "closed" is emitted. Retain + // the owner identity while it is alive; cleanup only compares references. + const owner = window.webContents + window.once("closed", () => this.removeOwner(owner)) + } + + private removeOwner(owner: WebContents): void { for (const [id, registration] of this.registrations) { if (registration.owner === owner) this.registrations.delete(id) } @@ -337,12 +356,20 @@ function stringValue(value: unknown): string { return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, 500) : "" } -async function withDebugger(guest: WebContents, deadline: number, operation: (debuggerSession: WebContents["debugger"]) => Promise): Promise { +async function withDebugger(guest: WebContents, deadline: number, operation: (debuggerSession: Pick) => Promise): Promise { const attached = guest.debugger.isAttached() if (!attached) guest.debugger.attach("1.3") + const debuggerSession = { + sendCommand: (...args: Parameters) => { + // A pending protocol reply can outlive the request deadline. Fence its + // continuation without detaching someone else's persistent overrides. + ensureDeadline(deadline) + return guest.debugger.sendCommand(...args) + }, + } try { - return await withDeadline(() => operation(guest.debugger), deadline, () => { - if (guest.debugger.isAttached()) guest.debugger.detach() + return await withDeadline(() => operation(debuggerSession), deadline, () => { + if (!attached && guest.debugger.isAttached()) guest.debugger.detach() }) } finally { if (!attached && guest.debugger.isAttached()) guest.debugger.detach() diff --git a/packages/electron-app/electron/main/browser-emulation.ts b/packages/electron-app/electron/main/browser-emulation.ts new file mode 100644 index 000000000..8b4a91ea9 --- /dev/null +++ b/packages/electron-app/electron/main/browser-emulation.ts @@ -0,0 +1,55 @@ +import type { WebContents } from "electron" +import profiles from "../../../ui/src/lib/native/browser-emulation.json" +import { browserEmulationProfile } from "../../../ui/src/lib/native/browser-emulation" + +// The debugger must remain attached while overrides are active. Automation's +// withDebugger preserves an existing attachment, so snapshots cannot reset it. +const ownedAttachments = new WeakSet() +const currentProfiles = new WeakMap() + +export async function setBrowserEmulation(guest: WebContents, preset: unknown): Promise { + browserEmulationProfile(preset) + const next = preset as string + const previous = currentProfiles.get(guest) ?? "none" + if (previous === next && (next === "none" || guest.debugger.isAttached())) return + if (!guest.debugger.isAttached()) { + guest.debugger.attach("1.3") + ownedAttachments.add(guest) + } + const apply = async (id: string) => { + const send = (method: string, params: object) => guest.debugger.sendCommand(method, params) + if (id === "none") { + await send("Emulation.clearDeviceMetricsOverride", {}) + await send("Emulation.setTouchEmulationEnabled", { enabled: false }) + await send("Emulation.setEmitTouchEventsForMouse", { enabled: false }) + await send("Emulation.setUserAgentOverride", { userAgent: "" }) + return + } + const profile = browserEmulationProfile(id)! + const version = await guest.debugger.sendCommand("Browser.getVersion") as { product: string } + const fullVersion = version.product.split("/").pop()! + await send("Emulation.setDeviceMetricsOverride", { + width: profile.width, height: profile.height, deviceScaleFactor: profile.deviceScaleFactor, + mobile: true, screenWidth: profile.width, screenHeight: profile.height, + screenOrientation: { type: profile.orientation, angle: profile.angle }, + }) + await send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 }) + await send("Emulation.setEmitTouchEventsForMouse", { enabled: true, configuration: "mobile" }) + await send("Emulation.setUserAgentOverride", { + userAgent: profiles.userAgent.replace("{version}", fullVersion), platform: "Linux armv8l", + userAgentMetadata: { brands: [{ brand: "Chromium", version: fullVersion.split(".")[0] }], + fullVersion, platform: "Android", platformVersion: "11.0.0", architecture: "", model: "", mobile: true }, + }) + } + try { + await apply(next) + currentProfiles.set(guest, next) + // Applications often cache UA detection at startup; apply before reloading. + guest.reload() + } catch (error) { + await apply(previous) + throw error + } finally { + if ((currentProfiles.get(guest) ?? "none") === "none" && ownedAttachments.delete(guest) && guest.debugger.isAttached()) guest.debugger.detach() + } +} diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index c8d3482d5..29f9745c7 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -114,6 +114,10 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD }) return { ok: true } }) + ipcMain.handle("browser-target:emulate", async (event, registrationId: unknown, preset: unknown) => { + const { window } = local(event) + await dependencies.browserController.emulate(window.webContents, registrationId, preset) + }) ipcMain.handle("browser-target:claimOpen", async (event, requestID: unknown) => { const window = local(event).window const claimed = dependencies.browserController.claimOpen(window.webContents, requestID) diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 690aecdf9..5e9e52ccc 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -26,7 +26,7 @@ import { navigateRemoteWindow, RemoteWindowRegistry } from "./remote-window-regi import { resolveConfiguredRendererOrigins } from "./renderer-origin" import { SerializedLifecycle } from "./serialized-lifecycle" import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, prepareSecondLaunchIntent, resolveRemoteSessionPartition, resolveStorageScope, startPrimaryInstance, type LaunchIntent } from "./startup" -import { clampWindowBounds, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, installWindowZoomInput, restoreWindowState, WindowStateTracker } from "./window-state" +import { clampWindowBounds, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, MIN_WINDOW_WIDTH, installWindowSizeConstraints, installWindowZoomInput, setWindowZoomLevel, zoomedWindowMinimum, restoreWindowState, WindowStateTracker } from "./window-state" import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" const mainDirname = dirname(fileURLToPath(import.meta.url)) @@ -222,10 +222,11 @@ function runPrimary(firstIntent: LaunchIntent) { 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 minimum = zoomedWindowMinimum(saved?.zoomFactor ?? 1) + const bounds = saved ? clampWindowBounds(saved.bounds, screen.getAllDisplays().map((display) => ({ ...display.workArea, scaleFactor: display.scaleFactor })), minimum) : 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, + ...(bounds ? { x: bounds.x, y: bounds.y } : {}), useContentSize: true, minWidth: minimum.width, minHeight: minimum.height, frame: false, autoHideMenuBar: true, backgroundColor: "#1a1a1a", icon: getIconPath(), webPreferences: { preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, webviewTag: true, @@ -243,12 +244,13 @@ function runPrimary(firstIntent: LaunchIntent) { lifecycle: navigationLifecycle, }) const tracker = persisted && clientState.isPrimary ? new WindowStateTracker(window, clientState, saved, windowId) : null + installWindowSizeConstraints(window, () => screen.getDisplayMatching(window.getBounds()).workArea, saved?.zoomFactor ?? 1) 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)) + installWindowZoomInput(window, (level) => tracker ? tracker.setZoomLevel(level) : setWindowZoomLevel(window, level)) setupNavigationGuards(window, navigation, getAllowedOrigins, getLoadingUrl) window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if (!secureBrowserWebview(webPreferences, params)) { @@ -287,8 +289,8 @@ function runPrimary(firstIntent: LaunchIntent) { clearWorkspaceMenuWindow(webContentsId) remoteOrigins.delete(nativeWindowId) insecureOrigins.delete(webContentsId) - browserController.removeOwner(window.webContents) }) + browserController.observeOwner(window) 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)) @@ -438,12 +440,14 @@ function runPrimary(firstIntent: LaunchIntent) { } 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, + width: 1400, height: 900, minWidth: MIN_WINDOW_WIDTH, minHeight: 600, backgroundColor: "#1a1a1a", icon: getIconPath(), title, webPreferences: { session: remoteSession, preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, additionalArguments: ["--codenomad-window-context=remote"] }, }) const nativeWindowId = window.id const webContentsId = window.webContents.id const allowedOrigins = new Set([base.origin, target.origin]) + installWindowSizeConstraints(window, () => screen.getDisplayMatching(window.getBounds()).workArea) + installWindowZoomInput(window, (level) => setWindowZoomLevel(window, level)) remoteWindows.register(payload.id, window, payload.proxySessionId) if (isMac) configureMediaPermissionHandlers(() => BrowserWindow.getAllWindows() .filter((candidate) => candidate.webContents.session === remoteSession) diff --git a/packages/electron-app/electron/main/menu.ts b/packages/electron-app/electron/main/menu.ts index 4748799c5..90ee62902 100644 --- a/packages/electron-app/electron/main/menu.ts +++ b/packages/electron-app/electron/main/menu.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, Menu, shell, type MenuItemConstructorOptions } from "electron" import { NEW_WINDOW_ACCELERATOR } from "./menu-target" +import { setWindowZoomLevel } from "./window-state" import { setViewMenuState, updateViewMenu, viewMenuItems } from "./view-menu" interface ApplicationMenuActions { @@ -104,9 +105,9 @@ function buildApplicationMenu() { { 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)) }, + { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: withTarget((window) => setWindowZoomLevel(window, 0)) }, + { label: "Zoom In", accelerator: "CmdOrCtrl+Plus", click: withTarget((window) => setWindowZoomLevel(window, window.webContents.getZoomLevel() + 0.5)) }, + { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: withTarget((window) => setWindowZoomLevel(window, window.webContents.getZoomLevel() - 0.5)) }, { type: "separator" }, { label: "Toggle Full Screen", accelerator: isMac ? "Ctrl+Command+F" : "F11", click: withTarget((window) => window.setFullScreen(!window.isFullScreen())) }, ] }, diff --git a/packages/electron-app/electron/main/window-state.test.ts b/packages/electron-app/electron/main/window-state.test.ts index cf2d90e78..9494bf150 100644 --- a/packages/electron-app/electron/main/window-state.test.ts +++ b/packages/electron-app/electron/main/window-state.test.ts @@ -1,12 +1,111 @@ import assert from "node:assert/strict" import { EventEmitter } from "node:events" import test from "node:test" -import { clampWindowBounds, installWindowZoomInput, normalizeNativeWindowState, normalizeZoomFactor, restoreWindowState, WindowStateTracker } from "./window-state" +import { clampWindowBounds, installWindowSizeConstraints, installWindowZoomInput, normalizeNativeWindowState, normalizeZoomFactor, restoreWindowState, setWindowZoomLevel, zoomedWindowMinimum, WindowStateTracker } from "./window-state" import type { BrowserWindow } from "electron" import type { ClientStateManager } from "./client-state" const primaryDisplay = { x: 0, y: 0, width: 1920, height: 1080 } +test("content minimum scales with zoom and restore does not impose the 1:1 baseline", () => { + for (const [zoom, width, height] of [[0.5, 195, 300], [1, 390, 600], [1.25, 488, 750], [1.5, 585, 900]]) { + const minimum = zoomedWindowMinimum(zoom) + assert.deepEqual(minimum, { width, height }) + assert.deepEqual(clampWindowBounds({ x: 20, y: 30, width: 100, height: 100 }, + [{ ...primaryDisplay, scaleFactor: 2 }], minimum), { x: 20, y: 30, width, height }) + } +}) + +test("zoom constraints include native chrome, grow undersized content and stay per-window", () => { + let factor = 1, width = 390, height = 600 + let minimum: number[] = [], destroyed = false + const contents = Object.assign(new EventEmitter(), { + isDestroyed: () => destroyed, + getZoomFactor: () => factor, + setZoomFactor: (next: number) => { factor = next }, + }) + const window = Object.assign(new EventEmitter(), { + isDestroyed: () => destroyed, + isMaximized: () => false, isFullScreen: () => false, + getContentSize: () => [width, height], + getBounds: () => ({ width: width + 16, height: height + 38 }), + setMinimumSize: (...size: number[]) => { minimum = size }, + setContentSize: (w: number, h: number) => { width = w; height = h }, + webContents: contents, + }) as unknown as BrowserWindow + installWindowSizeConstraints(window, () => ({ width: 1920, height: 1080 })) + assert.deepEqual(minimum, [406, 638]) + setWindowZoomLevel(window, Math.log(1.25) / Math.log(1.2)) + assert.deepEqual(minimum, [504, 788]) + assert.deepEqual([width, height], [488, 750]) + setWindowZoomLevel(window, Math.log(0.5) / Math.log(1.2)) + assert.deepEqual(minimum, [211, 338]) + assert.deepEqual([width, height], [488, 750], "zooming out must not shrink the user's window") + factor = 1.5 + contents.emit("did-finish-load") + assert.deepEqual(minimum, [601, 938]) + setWindowZoomLevel(window, 100) + assert.deepEqual(minimum, [1920, 1080], "an extreme zoom must not force bounds beyond the monitor") + destroyed = true + contents.emit("did-finish-load") +}) + +test("initial saved zoom seeds constraints before Chromium exposes its renderer zoom", () => { + let factor = 1, width = 312, height = 480 + let minimum: number[] = [] + const contents = Object.assign(new EventEmitter(), { + isDestroyed: () => false, getURL: () => "", getZoomFactor: () => factor, + setZoomFactor: (next: number) => { factor = next }, + }) + const window = Object.assign(new EventEmitter(), { + isDestroyed: () => false, isMaximized: () => false, isFullScreen: () => false, + getPosition: () => [0, 0], setPosition: () => undefined, + getContentSize: () => [width, height], getBounds: () => ({ width, height }), + setMinimumSize: (...size: number[]) => { minimum = size }, + setContentSize: (w: number, h: number) => { width = Math.max(w, minimum[0]); height = Math.max(h, minimum[1]) }, + webContents: contents, + }) as unknown as BrowserWindow + const state = { bounds: { x: 0, y: 0, width, height }, zoomFactor: 0.8, maximized: false, fullscreen: false } + installWindowSizeConstraints(window, () => primaryDisplay, state.zoomFactor) + assert.deepEqual(minimum, [312, 480]) + assert.deepEqual([width, height], [312, 480], "must not grow saved 80% bounds using transient 100% zoom") + restoreWindowState(window, state, state.bounds) + assert.equal(factor, 0.8) + assert.deepEqual([width, height], [312, 480]) + window.emit("closed") +}) + +test("tracker preserves seeded and explicitly changed zoom through transient navigation resets", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }) + let factor = 1 + const contents = Object.assign(new EventEmitter(), { + isDestroyed: () => false, getURL: () => "", getZoomFactor: () => factor, + setZoomFactor: (next: number) => { factor = next }, + }) + const window = Object.assign(new EventEmitter(), { + isDestroyed: () => false, isMaximized: () => false, isFullScreen: () => false, + getPosition: () => [0, 0], getContentSize: () => [312, 480], webContents: contents, + }) as unknown as BrowserWindow + const saved: number[] = [] + const manager = { activeWindowId: "saved", saveWindowState: async (state: { zoomFactor: number }) => { saved.push(state.zoomFactor); return true }, flush: async () => {} } + const tracker = new WindowStateTracker(window, manager, { + bounds: { x: 0, y: 0, width: 312, height: 480 }, zoomFactor: 0.8, maximized: false, fullscreen: false, + }) + contents.emit("did-start-navigation", {}, "http://fixture.test", false, true) + await tracker.flush() + assert.equal(saved.at(-1), 0.8, "flush during initial navigation must not persist transient native 100%") + contents.emit("did-finish-load") + assert.equal(factor, 0.8) + tracker.setZoomLevel(Math.log(1.25) / Math.log(1.2)) + contents.emit("did-start-navigation", {}, "http://fixture.test", false, true) + factor = 1 + await tracker.flush() + assert.equal(saved.at(-1), 1.25, "navigation reset must not overwrite the authoritative desired zoom") + contents.emit("did-finish-load") + assert.equal(factor, 1.25) + window.emit("closed") +}) + test("move/resize bursts debounce and final flush preserves pre-maximize bounds", async (t) => { t.mock.timers.enable({ apis: ["setTimeout"] }) const events = new EventEmitter() @@ -54,6 +153,10 @@ test("move/resize bursts debounce and final flush preserves pre-maximize bounds" }) test("normalizes persisted window state", () => { + for (const width of [320, 390, 430]) { + assert.deepEqual(clampWindowBounds({ x: 20, y: 30, width, height: 844 }, [primaryDisplay]), + { x: 20, y: 30, width: Math.max(390, width), height: 844 }) + } assert.equal(normalizeNativeWindowState({ bounds: { x: 0, y: 0, width: Number.NaN, height: 900 }, maximized: false, fullscreen: false, zoomFactor: 1 }), undefined) assert.deepEqual(clampWindowBounds({ x: 4000, y: 2000, width: 1400, height: 900 }, [primaryDisplay]), { x: 520, y: 180, width: 1400, height: 900 }) assert.deepEqual( @@ -69,7 +172,7 @@ test("mixed-DPI monitor selection compares intersections in physical coordinates { 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 }) + ), { x: 1000, y: 120, width: 700, height: 600 }) }) test("normalizes unsafe zoom factors", () => { @@ -81,11 +184,12 @@ test("normalizes unsafe zoom factors", () => { test("restores shared outer position and content size", () => { const calls: unknown[] = [] const window = { + isDestroyed: () => false, setPosition: (x: number, y: number) => calls.push(["position", x, y]), setContentSize: (width: number, height: number) => calls.push(["content", width, height]), maximize: () => undefined, setFullScreen: () => undefined, - webContents: { setZoomFactor: () => undefined }, + webContents: { isDestroyed: () => false, setZoomFactor: () => undefined }, } as unknown as BrowserWindow const bounds = { x: 10, y: 20, width: 1200, height: 800 } restoreWindowState(window, { bounds, maximized: false, fullscreen: false, zoomFactor: 1 }, bounds) diff --git a/packages/electron-app/electron/main/window-state.ts b/packages/electron-app/electron/main/window-state.ts index 1f7319500..3e02492e8 100644 --- a/packages/electron-app/electron/main/window-state.ts +++ b/packages/electron-app/electron/main/window-state.ts @@ -18,11 +18,14 @@ export interface NativeWindowState { export const DEFAULT_WINDOW_WIDTH = 1400 export const DEFAULT_WINDOW_HEIGHT = 900 -const MIN_WINDOW_WIDTH = 800 +export const MIN_WINDOW_WIDTH = 390 const MIN_WINDOW_HEIGHT = 600 const MIN_ZOOM_FACTOR = 0.25 const MAX_ZOOM_FACTOR = 5 const SAVE_DEBOUNCE_MS = 250 +type WindowSize = { width: number; height: number } +const windowMinimums = new Map WindowSize; zoomFactor: number; loaded: boolean }>() +const zoomObservers = new WeakMap void>() export interface DisplayWorkArea { x: number @@ -74,6 +77,81 @@ export function normalizeZoomFactor(value: unknown): number { return clamp(value, MIN_ZOOM_FACTOR, MAX_ZOOM_FACTOR) } +export function zoomedWindowMinimum(zoomFactor: number) { + const zoom = normalizeZoomFactor(zoomFactor) + return { width: Math.ceil(MIN_WINDOW_WIDTH * zoom), height: Math.ceil(MIN_WINDOW_HEIGHT * zoom) } +} + +function updateWindowMinimum(window: BrowserWindow): void { + const constraints = windowMinimums.get(window) + if (!constraints || window.isDestroyed() || window.webContents.isDestroyed()) return + const minimum = zoomedWindowMinimum(constraints.zoomFactor) + const [width, height] = window.getContentSize() + const outer = window.getBounds() + const chromeWidth = Math.max(0, outer.width - width) + const chromeHeight = Math.max(0, outer.height - height) + const area = constraints.workArea?.() + if (area) { + minimum.width = Math.min(minimum.width, Math.max(1, area.width - chromeWidth)) + minimum.height = Math.min(minimum.height, Math.max(1, area.height - chromeHeight)) + } + // Electron constrains outer bounds, while our baseline describes CSS content. + window.setMinimumSize(minimum.width + chromeWidth, minimum.height + chromeHeight) + if (!window.isMaximized() && !window.isFullScreen() && (width < minimum.width || height < minimum.height)) { + window.setContentSize(Math.max(width, minimum.width), Math.max(height, minimum.height)) + } +} + +export function installWindowSizeConstraints(window: BrowserWindow, workArea?: () => WindowSize, initialZoomFactor = 1): void { + // getZoomFactor() is still 1 before navigation, even with webPreferences.zoomFactor. + const constraints = { workArea, zoomFactor: normalizeZoomFactor(initialZoomFactor), loaded: false } + windowMinimums.set(window, constraints) + updateWindowMinimum(window) + window.webContents.on("did-start-navigation", (_event, _url, isInPlace, isMainFrame) => { + if (isMainFrame && !isInPlace) constraints.loaded = false + }) + window.webContents.on("did-finish-load", () => { + if (window.isDestroyed() || window.webContents.isDestroyed()) return + constraints.loaded = true + observeWindowZoom(window, window.webContents.getZoomFactor()) + }) + window.on("move", () => updateWindowMinimum(window)) + window.on("closed", () => windowMinimums.delete(window)) +} + +function observeWindowZoom(window: BrowserWindow, factor: number): void { + factor = normalizeZoomFactor(factor) + const constraints = windowMinimums.get(window) + if (constraints) constraints.zoomFactor = factor + zoomObservers.get(window)?.(factor) + updateWindowMinimum(window) +} + +function setWindowZoomFactor(window: BrowserWindow, factor: number): void { + if (window.isDestroyed() || window.webContents.isDestroyed()) return + factor = normalizeZoomFactor(factor) + const before = new Map() + for (const candidate of windowMinimums.keys()) { + if (!candidate.isDestroyed() && !candidate.webContents.isDestroyed()) { + before.set(candidate, candidate.webContents.getZoomFactor()) + } + } + window.webContents.setZoomFactor(factor) + observeWindowZoom(window, factor) + // Chromium shares host zoom within a Session without emitting zoom-changed + // on siblings. Read the actual affected factors instead of guessing host keys + // or separating Sessions (which would also separate authentication). + for (const [candidate, previous] of before) { + if (candidate === window || candidate.isDestroyed() || candidate.webContents.isDestroyed()) continue + const next = candidate.webContents.getZoomFactor() + if (windowMinimums.get(candidate)?.loaded || next !== previous) observeWindowZoom(candidate, next) + } +} + +export function setWindowZoomLevel(window: BrowserWindow, level: number): void { + setWindowZoomFactor(window, 1.2 ** level) +} + export function normalizeNativeWindowState(value: unknown): NativeWindowState | undefined { if (!value || typeof value !== "object") { return undefined @@ -150,11 +228,12 @@ export function restoreWindowState(window: BrowserWindow, state: NativeWindowSta return } + // Lower the native minimum before restoring zoomed-out saved content bounds. + setWindowZoomFactor(window, state.zoomFactor) if (bounds) { window.setPosition(bounds.x, bounds.y) window.setContentSize(bounds.width, bounds.height) } - window.webContents.setZoomFactor(normalizeZoomFactor(state.zoomFactor)) if (state.maximized) { window.maximize() } @@ -187,6 +266,7 @@ export function installWindowZoomInput(window: BrowserWindow, setZoomLevel: (lev export class WindowStateTracker { private saveTimer: ReturnType | undefined private desiredZoomFactor: number + private navigating: boolean private normalBounds: WindowBounds constructor( @@ -196,6 +276,12 @@ export class WindowStateTracker { private readonly windowId = clientState.activeWindowId, ) { this.desiredZoomFactor = normalizeZoomFactor(initialState?.zoomFactor) + this.navigating = typeof window.webContents.getURL === "function" && !window.webContents.getURL() + zoomObservers.set(window, (factor) => { + if (this.desiredZoomFactor === factor) return + this.desiredZoomFactor = factor + this.scheduleSave() + }) const [x, y] = typeof window.getPosition === "function" ? window.getPosition() : [0, 0] const [width, height] = typeof window.getContentSize === "function" ? window.getContentSize() @@ -212,18 +298,20 @@ export class WindowStateTracker { window.on(event as "maximize", () => this.scheduleSave()) } window.webContents.on("zoom-changed", () => this.scheduleSave()) - window.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => { - if (isMainFrame && !window.webContents.isDestroyed()) { - this.desiredZoomFactor = normalizeZoomFactor(window.webContents.getZoomFactor()) + window.webContents.on("did-start-navigation", (_event, _url, isInPlace, isMainFrame) => { + if (isMainFrame && !isInPlace && !window.webContents.isDestroyed()) { + if (!this.navigating) this.desiredZoomFactor = normalizeZoomFactor(window.webContents.getZoomFactor()) + this.navigating = true } }) window.webContents.on("did-finish-load", () => { if (!window.webContents.isDestroyed()) { - window.webContents.setZoomFactor(this.desiredZoomFactor) + this.navigating = false + setWindowZoomFactor(window, this.desiredZoomFactor) } }) window.on("close", () => { void this.saveNow() }) - window.on("closed", () => this.clearTimer()) + window.on("closed", () => { this.clearTimer(); zoomObservers.delete(window) }) } async flush(): Promise { @@ -236,8 +324,8 @@ export class WindowStateTracker { setZoomLevel(level: number): void { if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) return - this.window.webContents.setZoomLevel(level) - this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor()) + setWindowZoomLevel(this.window, level) + if (!this.navigating) this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor()) this.scheduleSave() } @@ -269,7 +357,9 @@ export class WindowStateTracker { return Promise.resolve(false) } - this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor()) + // A pending navigation can expose a temporary 100% renderer factor. Keep + // the seeded/explicitly reconciled factor authoritative until load finishes. + if (!this.navigating) this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor()) if (!this.window.isMaximized() && !this.window.isFullScreen()) this.captureNormalBounds() return this.clientState.saveWindowState({ bounds: this.normalBounds, diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 04ec71086..837f66bad 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -72,6 +72,7 @@ const localElectronAPI = { ipcRenderer.invoke("client-state:setRestoreEnabled", token, Boolean(enabled)), clearClientState: (token) => ipcRenderer.invoke("client-state:clear", token), registerBrowserTarget: (payload) => ipcRenderer.invoke("browser-target:register", payload), + emulateBrowserTarget: (registrationId, preset) => ipcRenderer.invoke("browser-target:emulate", registrationId, preset), unregisterBrowserTarget: (registrationId) => ipcRenderer.invoke("browser-target:unregister", registrationId), claimBrowserOpen: (requestId) => ipcRenderer.invoke("browser-target:claimOpen", requestId), releaseBrowserOpen: (requestId) => ipcRenderer.invoke("browser-target:releaseOpen", requestId), diff --git a/packages/server/package.json b/packages/server/package.json index 865a5185f..ab5067994 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -31,7 +31,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode/client": "2.0.18", + "@opencode/client": "2.0.21", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", @@ -46,7 +46,7 @@ }, "devDependencies": { "esbuild": "^0.25.12", - "@opencode/plugin": "2.0.18", + "@opencode/plugin": "2.0.21", "@types/node-forge": "^1.3.14", "@types/yauzl": "^2.10.0", "cross-env": "^7.0.3", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 3cbee67f9..7a93768f2 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -7,6 +7,7 @@ import type { RecentFolder, } from "./config/schema" import type { OpenCodeEvent } from "@opencode/client" +export type { GitHistoryCommit, GitHistoryPage, GitCommitFile, GitCommitDetails, GitCommitDiff } from "./git-history-types" export type { MissionActor, @@ -108,6 +109,7 @@ export interface ProviderUsageResponse { ok: boolean windows: Record fetchedAt: number + unavailableReason?: "native-credential-api-unavailable" } export type WorktreeKind = "root" | "worktree" @@ -204,6 +206,7 @@ export interface WorktreeGitDiffResponse { before: string after: string isBinary?: boolean + image?: import("./git-history-types").GitImageDiff } export interface WorktreeGitDiffRequest { @@ -675,6 +678,8 @@ export interface ServerMeta { /** Reachable addresses for this server, external first. */ addresses: NetworkAddress[] serverVersion?: string + /** CodeNomad backend OS and Node runtime architecture, never the UI or OpenCode host. */ + system?: { platform: string; arch: string } ui?: UiMeta support?: SupportMeta /** Optional update info (dev channel only). */ diff --git a/packages/server/src/git-history-types.ts b/packages/server/src/git-history-types.ts new file mode 100644 index 000000000..2bd2900bb --- /dev/null +++ b/packages/server/src/git-history-types.ts @@ -0,0 +1,42 @@ +export interface GitHistoryCommit { + id: string + parents: string[] + author: string + date: string + subject: string + refs: string +} + +export interface GitHistoryPage { + head: string | null + branch: string | null + commits: GitHistoryCommit[] + hasMore: boolean +} + +export interface GitCommitFile { + path: string + originalPath: string | null + status: string +} + +export interface GitCommitDetails { + id: string + parent: string | null + message: string + files: GitCommitFile[] +} + +export interface GitCommitDiff { + path: string + before: string + after: string + isBinary: boolean + image?: GitImageDiff +} + +export interface GitImageDiff { + mime: string + before: string | null + after: string | null +} diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index 48ee387ff..cebfaa3a7 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -277,29 +277,29 @@ test("beta version comparison remains numeric", () => { assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-10000", "0.0.0-beta-9999") > 0, true) }) -test("2.0.7 through 2.0.18 remain usable; recommendation only offers an optional update", async () => { - for (const version of ["2.0.7", "2.0.8", "2.0.9", "2.0.10", "2.0.11", "2.0.12", "2.0.13", "2.0.14", "2.0.15", "2.0.16", "2.0.17", "2.0.18"]) { +test("2.0.7 through 2.0.21 remain usable; recommendation only offers an optional update", async () => { + for (const version of ["2.0.7", "2.0.8", "2.0.9", "2.0.10", "2.0.11", "2.0.12", "2.0.13", "2.0.14", "2.0.15", "2.0.16", "2.0.17", "2.0.18", "2.0.19", "2.0.20", "2.0.21"]) { const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } rememberRuntime(endpoint, { version, pid: 123, discovery: "info" }) const service = new OpenCodeUpdateService(deps({ - probeBinary: () => ({ valid: true, version }), resolveLatestVersion: async () => "2.0.18", + probeBinary: () => ({ valid: true, version }), resolveLatestVersion: async () => "2.0.21", lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { throw new Error("must retain daemon") } }), })) const status = await service.start() assert.equal(status.minimumVersion, "2.0.7") - assert.equal(status.recommendedVersion, "2.0.18") + assert.equal(status.recommendedVersion, "2.0.21") assert.equal(status.state, "ready") assert.equal(status.serviceState, "ready") - assert.equal(status.versionAssessment, version === "2.0.18" ? "tested" : "untested", "only the current recommendation is release-qualified") + assert.equal(status.versionAssessment, version === "2.0.21" ? "tested" : "untested", "only the current recommendation is release-qualified") assert.equal(status.incompatibilityReason, undefined) - assert.equal(status.canUpgrade, version !== "2.0.18") + assert.equal(status.canUpgrade, version !== "2.0.21") assert.equal(status.canRestart, false) } }) test("a current daemon remains usable through an older selected discovery CLI", async () => { const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } - rememberRuntime(endpoint, { version: "2.0.18", pid: 123, discovery: "info" }) + rememberRuntime(endpoint, { version: "2.0.21", pid: 123, discovery: "info" }) const service = new OpenCodeUpdateService(deps({ probeBinary: () => ({ valid: true, version: "2.0.3" }), lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { throw new Error("must retain daemon") } }), diff --git a/packages/server/src/opencode/runtime-support.test.ts b/packages/server/src/opencode/runtime-support.test.ts index c42a299de..5a71060fe 100644 --- a/packages/server/src/opencode/runtime-support.test.ts +++ b/packages/server/src/opencode/runtime-support.test.ts @@ -7,8 +7,8 @@ import { OpenCodeSharedService } from "../workspaces/opencode-service" test("native timestamp minimum is distinct from recommendation and unfamiliar version labels", () => { assert.equal(MINIMUM_OPENCODE_VERSION, "2.0.7") - assert.equal(RECOMMENDED_OPENCODE_VERSION, "2.0.18") - for (const version of ["2.0.7", "2.0.8", "2.0.9", "2.0.10", "2.0.11", "2.0.12", "2.0.13", "2.0.14", "2.0.15", "2.0.16", "2.0.17", "2.0.18", "2.1.0", "2.0.18-dev.1", "3.0.0", "custom-build"]) { + assert.equal(RECOMMENDED_OPENCODE_VERSION, "2.0.21") + for (const version of ["2.0.7", "2.0.8", "2.0.9", "2.0.10", "2.0.11", "2.0.12", "2.0.13", "2.0.14", "2.0.15", "2.0.16", "2.0.17", "2.0.18", "2.0.19", "2.0.20", "2.0.21", "2.1.0", "2.0.21-dev.1", "3.0.0", "custom-build"]) { assert.equal(isBelowOpenCodeMinimum(version), false, version) } for (const version of ["2.0.0", "2.0.4", "2.0.5", "2.0.6", "0.0.0-beta-19507"]) assert.equal(isBelowOpenCodeMinimum(version), true, version) diff --git a/packages/server/src/opencode/runtime-support.ts b/packages/server/src/opencode/runtime-support.ts index 42e611196..da72bc80b 100644 --- a/packages/server/src/opencode/runtime-support.ts +++ b/packages/server/src/opencode/runtime-support.ts @@ -5,7 +5,7 @@ */ export const MINIMUM_OPENCODE_VERSION = "2.0.7" /** Qualification target, independent of the technically required minimum. */ -export const RECOMMENDED_OPENCODE_VERSION = "2.0.18" +export const RECOMMENDED_OPENCODE_VERSION = "2.0.21" export const OPENCODE_UPDATE_REQUIRED = "opencode_update_required" export function isBelowOpenCodeMinimum(version: string): boolean { diff --git a/packages/server/src/opencode/session-pruning/package.json b/packages/server/src/opencode/session-pruning/package.json index fbb47ed6f..4eda2391f 100644 --- a/packages/server/src/opencode/session-pruning/package.json +++ b/packages/server/src/opencode/session-pruning/package.json @@ -17,7 +17,7 @@ "transaction.ts", "tui.ts", "tui-reload.ts" ], "dependencies": { - "@opencode/plugin": "2.0.18", + "@opencode/plugin": "2.0.21", "zod": "^3.25.76" } } diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 8bfaaf23e..d32e9ab6e 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, it } from "node:test" import { Readable } from "node:stream" import Fastify, { type FastifyInstance } from "fastify" import replyFrom from "@fastify/reply-from" -import type { OpenCodeClient, SessionInfo } from "@opencode/client" +import { OpenCode, isSessionNotFoundError, type OpenCodeClient, type SessionInfo } from "@opencode/client" import type { Logger } from "../../logger" import { redactSecrets, registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server" import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" @@ -173,6 +173,19 @@ async function harness( } describe("instance proxy location enforcement", () => { + it("keeps native credential export and creation outside the workspace proxy", async () => { + const { app, requestCount } = await harness() + for (const method of ["GET", "POST"] as const) { + const response = await app.inject({ + method, + url: "/workspaces/workspace/instance/api/credential", + ...(method === "POST" ? { payload: { integrationID: "fixture", value: { type: "key", key: "synthetic-key" } } } : {}), + }) + assert.equal(response.statusCode, 403) + } + assert.equal(requestCount(), 0) + }) + it("forwards side generation for an owned busy session without mutating its environment", async () => { const { app, manager, sessionGets, requestCount } = await harness("/repo/worktree", { owned: { type: "running" } }) manager.getSessionEnvironment = async () => { throw new Error("Side generation must not replace the session environment") } @@ -762,6 +775,50 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 0) }) + it("preserves the missing-session contract without exposing upstream error details or forwarding", async () => { + const missing = Object.assign(new Error("private upstream details"), { + _tag: "SessionNotFoundError", sessionID: "untrusted-upstream-id", internal: "private", + }) + const { app, sessionGets, requestCount } = await harness("/repo/worktree", {}, { missing }) + for (const [method, suffix] of [["GET", ""], ["GET", "/message"], ["DELETE", ""]] as const) { + const response = await app.inject({ method, url: `/workspaces/workspace/instance/api/session/missing${suffix}` }) + assert.equal(response.statusCode, 404) + assert.deepEqual(response.json(), { + _tag: "SessionNotFoundError", sessionID: "missing", message: "Session not found", + }) + } + assert.deepEqual(sessionGets, ["missing", "missing", "missing"]) + assert.equal(requestCount(), 0) + + // Exercise decoding with the real browser SDK, not just its tag guard. + const client = OpenCode.make({ baseUrl: "http://fixture/workspaces/workspace/instance", fetch: async (input, init) => { + const request = new Request(input, init) + const response = await app.inject({ method: "GET", url: new URL(request.url).pathname }) + return new Response(response.body, { status: response.statusCode, headers: { "content-type": "application/json" } }) + } }) + await assert.rejects(client.session.get({ sessionID: "missing" }), error => { + assert.ok(error instanceof Error) + assert.ok(isSessionNotFoundError(error)) + assert.equal(error.sessionID, "missing") + assert.equal(error.message, "Session not found") + return true + }) + assert.equal(requestCount(), 0) + }) + + it("does not classify foreign sessions or unrelated lookup failures as missing sessions", async () => { + const { app, requestCount } = await harness("/repo/worktree", {}, { + foreign: "/other", unavailable: new Error("temporary lookup failure"), + }) + const foreign = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/foreign" }) + assert.equal(foreign.statusCode, 403) + assert.equal(isSessionNotFoundError(foreign.json()), false) + const unavailable = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/unavailable" }) + assert.equal(unavailable.statusCode, 500) + assert.equal(isSessionNotFoundError(unavailable.json()), false) + assert.equal(requestCount(), 0) + }) + it("filters active sessions to the workspace without failing on stale ids", async () => { const active = { owned: { type: "running" as const }, foreign: { type: "running" as const }, stale: { type: "running" as const } } const { app, sessionGets, requestCount } = await harness("/repo/worktree", active, { diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 272aeb194..164393110 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -341,7 +341,7 @@ export function createHttpServer(deps: HttpServerDeps) { workspaceManager: deps.workspaceManager, }) app.addHook("onClose", async () => developerCdp.close()) - registerUsageRoutes(app) + registerUsageRoutes(app, { workspaceManager: deps.workspaceManager }) registerMissionRoutes(app, { workspaceManager: deps.workspaceManager }) registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger }) @@ -930,7 +930,9 @@ async function proxyWorkspaceRequest(args: { return } if (isSessionNotFoundError(error)) { - reply.code(404).send({ error: "Session not found" }) + // Parent-chain hydration must distinguish a missing session from a + // transport/authorization failure. Retain only the native public shape. + reply.code(404).send({ _tag: "SessionNotFoundError", sessionID: sessionId, message: "Session not found" }) return } throw error diff --git a/packages/server/src/server/routes/files-history.test.ts b/packages/server/src/server/routes/files-history.test.ts new file mode 100644 index 000000000..bcfd565e7 --- /dev/null +++ b/packages/server/src/server/routes/files-history.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { execFileSync } from "node:child_process" +import Fastify from "fastify" +import { WorkspaceManager } from "../../workspaces/manager" +import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" +import { invalidateWorktreeCache } from "../../workspaces/worktree-directory" +import { registerWorkspaceRoutes } from "./workspaces" + +test("real Git history routes and bounded file previews use the requested owned worktree", async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "codenomad-files-")) + const root = path.join(temp, "root"), linked = path.join(temp, "linked"), outside = path.join(temp, "outside") + const git = (cwd: string, ...args: string[]) => execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", windowsHide: true }).trim() + const id = path.basename(temp) + const app = Fastify() + const ownershipReads: string[] = [] + try { + await mkdir(root); await mkdir(outside) + git(root, "init", "-b", "main") + git(root, "config", "user.name", "Fixture"); git(root, "config", "user.email", "fixture@example.invalid") + await writeFile(path.join(root, "notes.txt"), "root content") + git(root, "add", "."); git(root, "commit", "-m", "Root commit") + git(root, "worktree", "add", "-b", "feature", linked) + await writeFile(path.join(linked, "notes.txt"), "linked content") + git(linked, "add", "."); git(linked, "commit", "-m", "Linked commit") + await writeFile(path.join(outside, "secret.txt"), "outside authority") + await writeFile(path.join(linked, "image.png"), Buffer.from([137, 80, 78, 71, 0, 13, 10])) + await writeFile(path.join(linked, "oversize.bin"), Buffer.alloc(5 * 1024 * 1024 + 1)) + const workspace = { id, path: root } + // Exercise the actual manager fileBrowserRoot + previewFile boundary. Native + // discovery is isolated to an explicit inventory, with no shared daemon. + const manager = Object.assign(Object.create(WorkspaceManager.prototype), { + get: (requested: string) => requested === id ? workspace : undefined, + getWorktreeIdentityForPath: async (_id: string, directory: string) => directory === root || directory === linked ? directory : undefined, + requireWorkspace: (requested: string) => { if (requested !== id) throw new Error("Workspace not found"); return workspace }, + resolveOwnedWorktree: async (_workspace: unknown, directory: string) => { + ownershipReads.push(directory) + return directory === root || directory === linked ? { directory } : null + }, + getWorktrees: async () => ({ worktrees: [ + { slug: "root", directory: root, kind: "root" }, { slug: "linked", directory: linked, kind: "worktree" }, + ] }), + }) as WorkspaceManager + const fence = new WorktreeDeletionFence() + registerWorkspaceRoutes(app, { workspaceManager: manager, worktreeDeletionFence: fence }) + const base = `/api/workspaces/${id}` + const preview = (file: string, directory: string) => app.inject(`${base}/files/preview?${new URLSearchParams({ path: file, directory })}`) + const rootFile = await preview("notes.txt", root), linkedFile = await preview("notes.txt", linked) + assert.equal(rootFile.statusCode, 200); assert.equal(linkedFile.statusCode, 200) + assert.equal(Buffer.from(rootFile.json().contents, "base64").toString(), "root content") + assert.equal(Buffer.from(linkedFile.json().contents, "base64").toString(), "linked content") + const save = (directory: string, file = "notes.txt") => app.inject({ method: "PUT", + url: `${base}/files/content?${new URLSearchParams({ path: file, directory })}`, + payload: { contents: "edited workspace" } }) + assert.equal((await save(linked)).statusCode, 204) + assert.equal(await readFile(path.join(linked, "notes.txt"), "utf8"), "edited workspace") + assert.equal(await readFile(path.join(root, "notes.txt"), "utf8"), "root content") + assert.equal((await save(outside)).statusCode, 403) + assert.equal((await save(linked, "../outside/secret.txt")).statusCode, 400) + const identity = await manager.getWorktreeIdentityForPath(id, linked) as string + await fence.run(identity, [identity], async () => { assert.equal((await save(linked)).statusCode, 409) }) + assert.equal((await preview("image.png", linked)).json().encoding, "base64") + assert.equal((await preview("oversize.bin", linked)).statusCode, 400) + assert.equal((await preview("secret.txt", outside)).statusCode, 400) + assert.equal((await preview("../outside/secret.txt", root)).statusCode, 400) + assert.ok(ownershipReads.includes(outside)) + const history = await app.inject(`${base}/worktrees/linked/git-history`) + assert.equal(history.statusCode, 200) + assert.equal(history.json().branch, "feature") + assert.equal(history.json().commits[0].subject, "Linked commit") + const commit = history.json().commits[0].id + const diff = await app.inject(`${base}/worktrees/linked/git-history/${commit}?path=notes.txt`) + assert.equal(diff.statusCode, 200) + assert.equal(diff.json().before, "root content"); assert.equal(diff.json().after, "linked content") + assert.equal((await app.inject(`${base}/worktrees/missing/git-history`)).statusCode, 404) + assert.equal((await app.inject(`${base}/worktrees/root/git-history?head=--all`)).statusCode, 400) + assert.equal((await app.inject(`${base}/worktrees/root/git-history/${commit}?path=not-in-commit`)).statusCode, 400) + assert.equal(git(root, "branch", "--show-current"), "main") + } finally { invalidateWorktreeCache(id); await app.close(); await rm(temp, { recursive: true, force: true }) } +}) + +test("file preview remains available for an explicitly opened directory without Git", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-directory-preview-")) + try { + await writeFile(path.join(root, "readme.md"), "# Directory only") + const manager = Object.assign(Object.create(WorkspaceManager.prototype), { + requireWorkspace: () => ({ path: root }), + resolveOwnedWorktree: async (_workspace: unknown, directory: string) => directory === root ? { directory } : null, + }) as WorkspaceManager + const file = await manager.previewFile("fixture", "readme.md", root) + assert.equal(Buffer.from(file.contents, "base64").toString(), "# Directory only") + await assert.rejects(manager.previewFile("fixture", "readme.md", path.dirname(root)), /not owned/) + await manager.writeFileInDirectory("fixture", root, "readme.md", "# Edited directory") + assert.equal(await readFile(path.join(root, "readme.md"), "utf8"), "# Edited directory") + await assert.rejects(manager.writeFileInDirectory("fixture", path.dirname(root), "readme.md", "invalid"), /not owned/) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/packages/server/src/server/routes/meta.test.ts b/packages/server/src/server/routes/meta.test.ts new file mode 100644 index 000000000..2713269d5 --- /dev/null +++ b/packages/server/src/server/routes/meta.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" +import type { ServerMeta } from "../../api-types" +import { registerMetaRoutes } from "./meta" + +test("metadata reports the backend process platform regardless of client user agent", async () => { + const app = Fastify() + const meta: ServerMeta = { + localUrl: "http://127.0.0.1:9899", eventsUrl: "/api/events", host: "127.0.0.1", + listeningMode: "local", localPort: 9899, hostLabel: "fixture", workspaceRoot: "/fixture", addresses: [], + } + registerMetaRoutes(app, { serverMeta: meta }) + try { + for (const userAgent of ["Windows NT 10.0; Win64; x64", "Linux aarch64", "Macintosh; Intel Mac OS X"]) { + const response = await app.inject({ url: "/api/meta", headers: { "user-agent": userAgent } }) + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json().system, { platform: process.platform, arch: process.arch }) + assert.equal(response.json().workspaceRoot, meta.workspaceRoot) + } + assert.equal(meta.system, undefined, "route must not mutate shared metadata") + meta.system = { platform: "client-provided-platform", arch: "client-provided-arch" } + const response = await app.inject({ url: "/api/meta" }) + assert.deepEqual(response.json().system, { platform: process.platform, arch: process.arch }, + "backend process metadata takes precedence over a stale seeded snapshot") + } finally { await app.close() } +}) diff --git a/packages/server/src/server/routes/meta.ts b/packages/server/src/server/routes/meta.ts index e65405172..94d2a20ab 100644 --- a/packages/server/src/server/routes/meta.ts +++ b/packages/server/src/server/routes/meta.ts @@ -17,6 +17,7 @@ function buildMetaResponse(meta: ServerMeta): ServerMeta { return { ...meta, + system: { platform: process.platform, arch: process.arch }, localPort, remotePort: remote?.port, listeningMode: isWildcardHost(meta.host) || !isLoopbackHost(meta.host) ? "all" : "local", diff --git a/packages/server/src/server/routes/usage.test.ts b/packages/server/src/server/routes/usage.test.ts index ef4ca1184..fa2d6cf94 100644 --- a/packages/server/src/server/routes/usage.test.ts +++ b/packages/server/src/server/routes/usage.test.ts @@ -1,28 +1,117 @@ import assert from "node:assert/strict" import test from "node:test" import Fastify from "fastify" +import { OpenCode } from "@opencode/client" +import type { ServiceConnection } from "../../workspaces/opencode-service" +import { registerUsageRoutes, type UsageRouteDeps } from "./usage" -import { registerUsageRoutes } from "./usage" - -test("returns a typed unsupported result without exposing server details", async () => { - const app = Fastify() - registerUsageRoutes(app) - - try { - const response = await app.inject({ method: "GET", url: "/api/usage/unknown-provider?modelId=test-model" }) - assert.equal(response.statusCode, 200) - assert.deepEqual(response.json(), { - requestedProviderId: "unknown-provider", - providerId: null, - providerName: "unknown-provider", - modelId: "test-model", - supported: false, - configured: false, - ok: false, - windows: {}, - fetchedAt: response.json().fetchedAt, - }) - } finally { - await app.close() +function harness() { + const expires = Date.now() + 3600000 + const state = { nativeCalls: [] as string[], quotaCalls: 0, directory: "/wsl/repo", switched: false, oldEndpoint: false, fail: false } + const logs: string[] = [] + const app = Fastify({ logger: { stream: { write: (text: string) => { logs.push(text) } } } }) + const workspace = {} as NonNullable> + const client = OpenCode.make({ baseUrl: "http://isolated-native.invalid", fetch: async (input) => { + const url = new URL(String(input)); state.nativeCalls.push(url.pathname) + if (state.fail) throw new Error("secret-refresh Authorization secret-token") + if (url.pathname === "/api/session/missing") return Response.json({ _tag: "SessionNotFoundError", sessionID: "missing" }, { status: 404 }) + if (url.pathname.startsWith("/api/session/")) return Response.json({ data: { id: "session", location: { directory: state.directory } } }) + if (url.pathname.startsWith("/api/provider/")) return Response.json({ location: { directory: state.directory }, data: { + id: "openai", name: "OpenAI", activation: "auto", package: "openai", + } }) + if (url.pathname === "/api/integration/openai") return Response.json({ location: { directory: state.directory }, data: { + id: "openai", name: "OpenAI", methods: [], connections: [{ type: "credential", method: "oauth", id: state.switched ? "other" : "selected", label: "Secret account" }], + } }) + if (url.pathname === "/api/credential") return Response.json({ data: [{ id: "selected", integrationID: "openai", active: true, label: "Secret account", + value: { type: "oauth", methodID: "chatgpt-headless", access: "secret-token", refresh: "secret-refresh", expires, + metadata: { accountID: "secret-account" } }, + }] }, { status: state.oldEndpoint ? 404 : 200 }) + return Response.json({}, { status: 404 }) + } }) + const connection = { client, assertCurrent: () => {} } as ServiceConnection + const manager: UsageRouteDeps["workspaceManager"] = { + get: id => id === "instance" ? workspace : undefined, + getSharedServiceConnection: async () => connection, + ownsLocation: async (_id, location) => location.directory === "/wsl/repo", } + registerUsageRoutes(app, { workspaceManager: manager }) + return { app, state, logs } +} +const url = "/api/usage/openai?instanceId=instance&sessionId=session&modelId=gpt-5" + +test("rejects missing/unknown workspace and missing/foreign session before credential access", async () => { + const { app, state } = harness() + try { + assert.equal((await app.inject({ url: "/api/usage/openai" })).statusCode, 400) + assert.equal((await app.inject({ url: url.replace("instanceId=instance", "instanceId=unknown") })).statusCode, 404) + assert.equal(state.nativeCalls.length, 0) + assert.equal((await app.inject({ url: url.replace("sessionId=session", "sessionId=missing") })).statusCode, 404) + state.directory = "/foreign" + assert.equal((await app.inject({ url })).statusCode, 403) + assert.ok(state.nativeCalls.every(path => path.startsWith("/api/session/"))) + } finally { await app.close() } +}) + +test("owned native quota response is normalized; credentials and upstream errors never reach API or logs", async () => { + const { app, state, logs } = harness() + const previousFetch = globalThis.fetch + globalThis.fetch = async (_input, init) => { + state.quotaCalls++ + assert.equal(new Headers(init?.headers).get("authorization"), "Bearer secret-token") + return Response.json({ rate_limit: { primary_window: { used_percent: 25, limit_window_seconds: 18000 } } }) + } + try { + const response = await app.inject({ url }) + assert.equal(response.statusCode, 200) + assert.equal(response.headers["cache-control"], "no-store") + assert.equal(response.json().ok, true, JSON.stringify({ body: response.json(), calls: state.nativeCalls })) + assert.equal(response.json().windows["5h"].usedPercent, 25) + assert.equal((await app.inject({ url })).json().ok, true) + assert.equal(state.quotaCalls, 1) + state.switched = true + const switched = await app.inject({ url }) + assert.equal(switched.json().ok, false) + assert.deepEqual(switched.json().windows, {}) + state.fail = true + const failed = await app.inject({ url }) + assert.equal(failed.statusCode, 503) + assert.equal([response.body, switched.body, failed.body, ...logs].join("").includes("secret-"), false) + } finally { globalThis.fetch = previousFetch; await app.close() } +}) + +test("older native credential endpoint is feature-local unavailability, not a host fallback", async () => { + const { app, state } = harness() + state.oldEndpoint = true + const previousFetch = globalThis.fetch + globalThis.fetch = async () => { throw new Error("No legacy fallback permitted") } + try { + const response = await app.inject({ url }) + assert.equal(response.statusCode, 200) + assert.equal(response.json().supported, true) + assert.equal(response.json().ok, false) + assert.equal(response.json().unavailableReason, "native-credential-api-unavailable") + assert.deepEqual(response.json().windows, {}) + } finally { globalThis.fetch = previousFetch; await app.close() } +}) + +test("session movement during quota fetch fences publication", async () => { + const { app, state } = harness() + const previousFetch = globalThis.fetch + globalThis.fetch = async () => { state.directory = "/foreign"; return Response.json({ rate_limit: { primary_window: { used_percent: 10 } } }) } + try { + const response = await app.inject({ url }) + assert.equal(response.statusCode, 403) + assert.equal(response.body.includes("windows"), false) + } finally { globalThis.fetch = previousFetch; await app.close() } +}) + +test("owned unknown provider retains a typed unsupported result without a quota request", async () => { + const { app } = harness() + try { + const response = await app.inject({ url: url.replace("/usage/openai", "/usage/unknown-provider") }) + assert.equal(response.statusCode, 200) + assert.equal(response.json().providerId, null) + assert.equal(response.json().supported, false) + assert.deepEqual(response.json().windows, {}) + } finally { await app.close() } }) diff --git a/packages/server/src/server/routes/usage.ts b/packages/server/src/server/routes/usage.ts index 24e414734..f7a9e3402 100644 --- a/packages/server/src/server/routes/usage.ts +++ b/packages/server/src/server/routes/usage.ts @@ -1,24 +1,91 @@ import type { FastifyInstance } from "fastify" import { z } from "zod" - -import { getProviderUsage } from "../../usage/service" +import type { WorkspaceManager } from "../../workspaces/manager" +import { readLocationRef } from "../../opencode/compatibility/location" +import { createNativeCodexUsage } from "../../usage/native-codex" +import { getProviderUsage, resolveUsageProvider } from "../../usage/service" const UsageParamsSchema = z.object({ providerId: z.string().trim().min(1) }) -const UsageQuerySchema = z.object({ modelId: z.string().trim().optional() }) +const UsageQuerySchema = z.object({ + instanceId: z.string().trim().min(1).max(256), sessionId: z.string().trim().min(1).max(256), + modelId: z.string().trim().max(256).optional(), +}).strict() -export function registerUsageRoutes(app: FastifyInstance) { - app.get<{ Params: { providerId: string }; Querystring: { modelId?: string } }>( +export interface UsageRouteDeps { + workspaceManager: Pick +} + +export function registerUsageRoutes(app: FastifyInstance, deps: UsageRouteDeps) { + const nativeCodexUsage = createNativeCodexUsage() + app.get<{ Params: { providerId: string }; Querystring: z.infer }>( "/api/usage/:providerId", async (request, reply) => { + reply.header("Cache-Control", "no-store") + const params = UsageParamsSchema.safeParse(request.params) + const query = UsageQuerySchema.safeParse(request.query ?? {}) + if (!params.success || !query.success) return reply.code(400).send({ error: "Invalid usage request" }) + const { instanceId, sessionId, modelId } = query.data + const manager = deps.workspaceManager + const workspace = manager.get(instanceId) + if (!workspace) return reply.code(404).send({ error: "Workspace not found" }) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15_000) + const signal = controller.signal try { - const params = UsageParamsSchema.parse(request.params) - const query = UsageQuerySchema.parse(request.query ?? {}) - return await getProviderUsage(params.providerId, { modelId: query.modelId }) + return await boundedRead(async () => { + const connection = await manager.getSharedServiceConnection(instanceId) + signal.throwIfAborted() + if (!connection) return reply.code(503).send({ error: "Provider usage unavailable" }) + connection.assertCurrent() + signal.throwIfAborted() + const session = await connection.client.session.get({ sessionID: sessionId }, { signal }) + const location = readLocationRef(session.location) + const owned = location.workspaceID === undefined && await manager.ownsLocation(instanceId, location, connection.client) + signal.throwIfAborted() + if (!owned) { + return reply.code(403).send({ error: "Session does not belong to workspace" }) + } + signal.throwIfAborted() + connection.assertCurrent() + const provider = resolveUsageProvider(params.data.providerId) + const native = !provider || provider.id === "codex" + ? await nativeCodexUsage(connection, { instanceId, sessionId, directory: location.directory, + providerId: params.data.providerId, modelId }, signal) + : null + const usage = native ?? await getProviderUsage(params.data.providerId, { modelId }) + // Session movement, workspace eviction and reconnect fence publication too. + const current = await connection.client.session.get({ sessionID: sessionId }, { signal }) + const currentLocation = readLocationRef(current.location) + connection.assertCurrent() + signal.throwIfAborted() + const stillOwned = manager.get(instanceId) === workspace && currentLocation.directory === location.directory + && currentLocation.workspaceID === undefined && await manager.ownsLocation(instanceId, currentLocation, connection.client) + signal.throwIfAborted() + if (!stillOwned) { + return reply.code(403).send({ error: "Session does not belong to workspace" }) + } + signal.throwIfAborted() + connection.assertCurrent() + return usage + }, signal) } catch (error) { - request.log.error({ err: error }, "Failed to fetch provider usage") - reply.code(400) - return { error: error instanceof Error ? error.message : "Failed to fetch provider usage" } + // Do not serialize/log SDK errors: credential exports and request bodies + // are server-only, and arbitrary upstream errors can contain secrets. + const missing = error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError" + return reply.code(missing ? 404 : 503).send({ error: missing ? "Session not found" : "Provider usage unavailable" }) + } finally { + clearTimeout(timeout) } }, ) } + +async function boundedRead(operation: () => Promise, signal: AbortSignal): Promise { + let abort!: () => void + const aborted = new Promise((_resolve, reject) => { + abort = () => reject(new Error("Provider usage timed out")) + signal.addEventListener("abort", abort, { once: true }) + }) + try { return await Promise.race([operation(), aborted]) } + finally { signal.removeEventListener("abort", abort) } +} diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index d9ae3b64f..08cf87f73 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -10,6 +10,7 @@ import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-ev import { WorkspaceSearchBusyError } from "../../filesystem/search-cache" import { UnsupportedOpenCodeError } from "../../opencode/runtime-support" import { GitRequiredError } from "../../workspaces/git-requirement" +import { getGitHistory, getGitCommit, getGitCommitDiff } from "../../workspaces/git-history" interface RouteDeps { workspaceManager: WorkspaceManager @@ -39,6 +40,7 @@ const WorkspaceFilesQuerySchema = z.object({ const WorkspaceFileContentQuerySchema = z.object({ path: z.string(), + directory: z.string().trim().min(1).optional(), encoding: z.enum(["utf-8", "base64"]).optional(), worktree: z.string().trim().optional(), }) @@ -73,6 +75,31 @@ const WorkspaceFileSearchQuerySchema = z.object({ }) export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get<{ Params: { id: string } }>("/api/workspaces/:id/files/preview", async (request, reply) => { + try { + const query = z.object({ path: z.string().min(1), directory: z.string().min(1).optional() }).parse(request.query) + return await deps.workspaceManager.previewFile(request.params.id, query.path, query.directory) + } catch (error) { return handleWorkspaceError(error, reply) } + }) + app.get<{ Params: { id: string; slug: string } }>("/api/workspaces/:id/worktrees/:slug/git-history", async (request, reply) => { + try { + const query = z.object({ offset: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0), head: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/).optional() }).parse(request.query) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + return await getGitHistory(directory, query.offset, query.head) + } catch (error) { return handleWorkspaceError(error, reply) } + }) + + app.get<{ Params: { id: string; slug: string; commit: string } }>("/api/workspaces/:id/worktrees/:slug/git-history/:commit", async (request, reply) => { + try { + const query = z.object({ path: z.string().min(1).optional() }).parse(request.query) + const commit = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/).parse(request.params.commit) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + return query.path === undefined ? await getGitCommit(directory, commit) + : await getGitCommitDiff(directory, commit, query.path) + } catch (error) { return handleWorkspaceError(error, reply) } + }) app.get("/api/workspaces", async () => { return deps.workspaceManager.list() }) @@ -200,6 +227,15 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { try { const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) const body = WorkspaceFileContentBodySchema.parse(request.body ?? {}) + if (query.directory) { + const directory = query.directory + const mutation = await runWorktreeMutation(deps, request.params.id, directory, reply, async () => { + await deps.workspaceManager.writeFileInDirectory(request.params.id, directory, query.path, body.contents) + }) + if (!mutation) return + reply.code(204) + return + } if (query.worktree && query.worktree !== "root") { const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, query.worktree, request.log, reply) if (!directory) return diff --git a/packages/server/src/usage/native-codex.test.ts b/packages/server/src/usage/native-codex.test.ts new file mode 100644 index 000000000..376b060e8 --- /dev/null +++ b/packages/server/src/usage/native-codex.test.ts @@ -0,0 +1,252 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { once } from "node:events" +import fs from "node:fs" +import test from "node:test" +import { OpenCode, type CredentialEntry } from "@opencode/client" +import type { ServiceConnection } from "../workspaces/opencode-service" +import { createNativeCodexUsage } from "./native-codex" + +const quota = (used = 25) => ({ rate_limit: { primary_window: { used_percent: used, limit_window_seconds: 18_000 } } }) +const credential = (id = "selected", accountID = "native-account"): CredentialEntry => ({ + id, integrationID: "openai", label: "Private account label", active: true, + value: { type: "oauth", methodID: "chatgpt-browser", access: `secret-access-${id}`, refresh: "secret-refresh", + expires: Date.now() + 3_600_000, metadata: { accountID } }, +}) + +async function fixture() { + const state = { integrationID: "openai", selected: { type: "credential", id: "selected", label: "Private", method: "oauth" } as Record, + entries: [credential()], status: 200, current: true, calls: [] as string[] } + const server = createServer((req, res) => { + state.calls.push(req.url!) + assert.equal(req.headers.authorization, "Bearer native-daemon-secret") + res.setHeader("content-type", "application/json") + if (req.url!.startsWith("/api/provider/")) res.end(JSON.stringify({ location: { directory: "/wsl/project" }, data: { + id: "model-provider", name: "OpenAI", integrationID: state.integrationID, activation: "auto", package: "openai", + } })) + else if (req.url!.startsWith("/api/integration/")) res.end(JSON.stringify({ location: { directory: "/wsl/project" }, data: { + id: state.integrationID, name: "OpenAI", methods: [], connections: [state.selected], + } })) + else if (req.url === "/api/credential") { res.statusCode = state.status; res.end(JSON.stringify({ data: state.entries })) } + else { res.statusCode = 404; res.end("{}") } + }) + server.listen(0, "127.0.0.1") + await once(server, "listening") + const address = server.address() as { port: number } + const client = OpenCode.make({ baseUrl: `http://127.0.0.1:${address.port}`, headers: { authorization: "Bearer native-daemon-secret" } }) + const connection = { client, assertCurrent: () => { if (!state.current) throw new Error("stale connection secret") } } as ServiceConnection + return { state, connection, close: () => new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections() }) } +} +const scope = { instanceId: "instance", sessionId: "session", directory: "/wsl/project", providerId: "model-provider", modelId: "gpt-5" } + +test("native location/integration mapping and accountID win without reading host legacy or CLI auth", async () => { + const f = await fixture() + const previousFetch = globalThis.fetch + const previousRead = fs.readFileSync + let providerCalls = 0 + fs.readFileSync = (() => { throw new Error("host credential read forbidden") }) as typeof fs.readFileSync + globalThis.fetch = async (input, init) => { + if (String(input).startsWith("http://127.0.0.1")) return previousFetch(input, init) + providerCalls++ + assert.equal(String(input), "https://chatgpt.com/backend-api/wham/usage") + assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer secret-access-selected") + assert.equal(new Headers(init?.headers).get("ChatGPT-Account-Id"), "native-account") + assert.equal(init?.redirect, "error") + assert.ok(init?.signal) + return Response.json(quota()) + } + try { + const usage = createNativeCodexUsage() + const response = await usage(f.connection, scope, AbortSignal.timeout(2000)) + assert.equal(response?.ok, true) + assert.equal(response?.windows["5h"].usedPercent, 25) + assert.equal((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.ok, true) + assert.equal(providerCalls, 1) + assert.ok(f.state.calls.some(url => url.startsWith("/api/provider/model-provider?") && new URL(url, "http://fixture").searchParams.get("location[directory]") === "/wsl/project")) + assert.ok(f.state.calls.some(url => url.startsWith("/api/integration/openai?"))) + assert.equal(JSON.stringify(response).includes("secret"), false) + assert.equal(JSON.stringify(response).includes("native-account"), false) + } finally { fs.readFileSync = previousRead; globalThis.fetch = previousFetch; await f.close() } +}) + +test("only the active selected native ChatGPT OAuth entry may authorize subscription usage", async () => { + const f = await fixture() + const previousFetch = globalThis.fetch + let calls = 0 + globalThis.fetch = (input, init) => { + if (String(input).startsWith("http://127.0.0.1")) return previousFetch(input, init) + calls++; throw new Error("must not use a different account or refresh") + } + try { + const usage = createNativeCodexUsage() + const invalid = [ + { selected: { type: "credential", id: "key", method: "key", label: "Key" }, entries: [{ ...credential("key"), value: { type: "key", key: "secret-key" } }, { ...credential(), active: false }] }, + { selected: { type: "env", name: "OPENAI_API_KEY" }, entries: [credential()] }, + { selected: { ...f.state.selected, status: { status: "needs_auth", message: "secret" } }, entries: [credential()] }, + { entries: [{ ...credential(), active: false }] }, + { entries: [credential("different")] }, + { entries: [{ ...credential(), integrationID: "other" }] }, + { entries: [{ ...credential(), value: { ...credential().value, type: "oauth", methodID: "other", access: "secret", refresh: "secret", expires: Date.now() + 3600000 } }] }, + { entries: [{ ...credential(), value: { type: "oauth", methodID: "chatgpt-headless", access: "expired", refresh: "secret", expires: 1 } }] }, + { entries: [{ ...credential(), value: { type: "oauth", methodID: "chatgpt-browser", access: "", refresh: "secret", expires: Date.now() + 3600000 } }] }, + ] + for (const change of invalid) { + f.state.selected = { type: "credential", id: "selected", method: "oauth", label: "Private" } + Object.assign(f.state, change) + const response = await usage(f.connection, scope, AbortSignal.timeout(2000)) + assert.equal(response?.ok, false) + assert.deepEqual(response?.windows, {}) + assert.equal(JSON.stringify(response).includes("secret"), false) + } + f.state.selected = { type: "credential", id: "selected", method: "oauth", label: "Private" } + f.state.entries = [credential()]; f.state.status = 404 + assert.equal((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.ok, false) + assert.equal(calls, 0) + } finally { globalThis.fetch = previousFetch; await f.close() } +}) + +test("warm snapshots and pending quotas cannot cross selected accounts or native connection generations", async () => { + const f = await fixture() + const previousFetch = globalThis.fetch + let release!: () => void + let started!: () => void + let calls = 0 + let gate: Promise | undefined + let entered: Promise | undefined + globalThis.fetch = async (input, init) => { + if (String(input).startsWith("http://127.0.0.1")) return previousFetch(input, init) + const used = ++calls + started?.(); await gate + return Response.json(quota(used)) + } + try { + const usage = createNativeCodexUsage() + assert.equal((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.windows["5h"].usedPercent, 1) + f.state.selected.id = "second"; f.state.entries = [credential("second", "second-account")] + assert.equal((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.windows["5h"].usedPercent, 2) + // Same credential ID can be replaced with a different account/token. + f.state.entries = [credential("second", "third-account")] + gate = new Promise(resolve => { release = resolve }); entered = new Promise(resolve => { started = resolve }) + const pending = usage(f.connection, scope, AbortSignal.timeout(2000)) + await entered + f.state.entries = [credential("second", "fourth-account")] + release() + assert.equal((await pending)?.ok, false) + gate = undefined + assert.equal((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.windows["5h"].usedPercent, 4) + f.state.current = false + assert.notEqual((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.ok, true) + f.state.current = true + const reconnected = { ...f.connection } + assert.equal((await usage(reconnected, scope, AbortSignal.timeout(2000)))?.windows["5h"].usedPercent, 5) + } finally { release?.(); globalThis.fetch = previousFetch; await f.close() } +}) + +test("a missing native credential API exposes only the feature-local upgrade reason", async () => { + const f = await fixture() + try { + const usage = createNativeCodexUsage() + for (const status of [404, 401, 403, 500]) { + f.state.status = status + const response = await usage(f.connection, scope, AbortSignal.timeout(2000)) + assert.equal(response?.ok, false) + assert.deepEqual(response?.windows, {}) + assert.equal(response?.unavailableReason, status === 404 ? "native-credential-api-unavailable" : undefined) + assert.equal(JSON.stringify(response).includes("secret"), false) + assert.equal(JSON.stringify(response).includes("Private"), false) + } + } finally { await f.close() } +}) + +test("quota failures are bounded and sanitized without credential refresh or fallback", async () => { + const f = await fixture() + const previousFetch = globalThis.fetch + const failures = [ + async () => new Response("secret-access-selected", { status: 401 }), + async () => new Response("secret-refresh", { status: 403 }), + async () => Response.json({ secret: "secret-refresh" }), + async () => { throw new Error("Authorization: secret-access-selected") }, + async () => new Response("not json secret"), + async () => new Response("x".repeat(70_000)), + async () => new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("{\"secret\":")) } })), + async (_input: unknown, init?: RequestInit) => new Promise((_resolve, reject) => { + init!.signal!.addEventListener("abort", () => reject(new Error("secret timeout")), { once: true }) + }), + ] + try { + for (const fail of failures) { + let calls = 0 + globalThis.fetch = (input, init) => { + if (String(input).startsWith("http://127.0.0.1")) return previousFetch(input, init) + calls++; return fail(input, init) + } + const response = await createNativeCodexUsage()(f.connection, scope, AbortSignal.timeout(100)) + assert.equal(response?.ok, false) + assert.deepEqual(response?.windows, {}) + assert.equal(JSON.stringify(response).includes("secret"), false) + assert.equal(calls, 1) + } + } finally { globalThis.fetch = previousFetch; await f.close() } +}) + +test("account metadata wins over JWT; headless JWT fallback belongs only to the selected token", async () => { + const f = await fixture() + const previousFetch = globalThis.fetch + const accounts: string[] = [] + const token = (exp = Math.floor(Date.now() / 1000) + 3600) => `header.${Buffer.from(JSON.stringify({ + exp, "https://api.openai.com/auth": { chatgpt_account_id: "jwt-selected-account" }, + })).toString("base64url")}.signature` + globalThis.fetch = (input, init) => { + if (String(input).startsWith("http://127.0.0.1")) return previousFetch(input, init) + accounts.push(new Headers(init?.headers).get("ChatGPT-Account-Id")!) + return Promise.resolve(Response.json(quota())) + } + try { + const usage = createNativeCodexUsage() + const entry = credential() + assert.equal(entry.value.type, "oauth") + if (entry.value.type !== "oauth") throw new Error("fixture type") + entry.value.access = token() + f.state.entries = [entry] + assert.equal((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.ok, true) + entry.value.metadata = undefined; entry.value.methodID = "chatgpt-headless" + assert.equal((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.ok, true) + assert.deepEqual(accounts, ["native-account", "jwt-selected-account"]) + entry.value.access = token(1) + assert.equal((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.ok, false) + entry.value.access = "opaque"; entry.value.metadata = { accountId: "wrong-case-legacy-account" } + assert.equal((await usage(f.connection, scope, AbortSignal.timeout(2000)))?.ok, false) + assert.equal(accounts.length, 2) + } finally { globalThis.fetch = previousFetch; await f.close() } +}) + +test("pending reads share only the same native connection/instance/session/provider scope", async () => { + const f = await fixture() + const previousFetch = globalThis.fetch + let release!: () => void + let entered!: () => void + const gate = new Promise(resolve => { release = resolve }) + const started = new Promise(resolve => { entered = resolve }) + let calls = 0 + globalThis.fetch = async (input, init) => { + if (String(input).startsWith("http://127.0.0.1")) return previousFetch(input, init) + calls++; entered(); await gate + return Response.json(quota()) + } + try { + const usage = createNativeCodexUsage() + const first = usage(f.connection, scope, AbortSignal.timeout(2000)) + await started + const duplicate = usage(f.connection, scope, AbortSignal.timeout(2000)) + // Wait for the second real generated-client selection read, not a timer. + const deadline = Date.now() + 1000 + while (f.state.calls.filter(url => url === "/api/credential").length < 2 && Date.now() < deadline) await new Promise(resolve => setImmediate(resolve)) + release() + assert.equal((await first)?.ok, true) + assert.equal((await duplicate)?.ok, true) + assert.equal(calls, 1) + assert.equal((await usage(f.connection, { ...scope, instanceId: "another" }, AbortSignal.timeout(2000)))?.ok, true) + assert.equal((await usage(f.connection, { ...scope, sessionId: "another" }, AbortSignal.timeout(2000)))?.ok, true) + assert.equal(calls, 3) + } finally { release(); globalThis.fetch = previousFetch; await f.close() } +}) diff --git a/packages/server/src/usage/native-codex.ts b/packages/server/src/usage/native-codex.ts new file mode 100644 index 000000000..d28eb272a --- /dev/null +++ b/packages/server/src/usage/native-codex.ts @@ -0,0 +1,157 @@ +import { createHash } from "node:crypto" +import { ClientError } from "@opencode/client" +import type { ServiceConnection } from "../workspaces/opencode-service" +import type { ProviderUsageResponse } from "../api-types" +import type { ProviderUsage } from "./types" +import { decodeJwtClaims, getString } from "./shared" +import { parseCodexUsage } from "./providers/oauth" + +interface UsageScope { + instanceId: string + sessionId: string + directory: string + providerId: string + modelId?: string +} +type Snapshot = { identity: string; usage: ProviderUsage; fetchedAt: number } +const CACHE_TTL_MS = 60_000 +const MAX_RESPONSE_BYTES = 65_536 + +// Only normalized quota snapshots and irreversible identity digests are retained. +// Native credential exports (including refresh tokens) stay on the server stack. +export function createNativeCodexUsage() { + const caches = new WeakMap>() + const pending = new WeakMap>>() + + return async (connection: ServiceConnection, scope: UsageScope, signal: AbortSignal): Promise => { + let unavailableReason: ProviderUsageResponse["unavailableReason"] + const response = (snapshot?: Snapshot): ProviderUsageResponse => ({ + requestedProviderId: scope.providerId, providerId: "codex", providerName: "Codex", modelId: scope.modelId, + supported: true, configured: true, ok: Boolean(snapshot), windows: snapshot?.usage.windows ?? {}, + fetchedAt: snapshot?.fetchedAt ?? Date.now(), + ...(unavailableReason ? { unavailableReason } : {}), + }) + const location = { directory: scope.directory } + const key = JSON.stringify([scope.instanceId, scope.sessionId, scope.directory, scope.providerId]) + let requestKey: string | undefined + let request: Promise | undefined + let recognized = ["openai", "codex", "chatgpt"].includes(scope.providerId.toLowerCase()) + const readSelected = async () => { + connection.assertCurrent() + signal.throwIfAborted() + const provider = await connection.client.provider.get({ providerID: scope.providerId, location }, { signal }) + if (provider.location.directory !== scope.directory || provider.data.id !== scope.providerId) throw new Error("Provider location changed") + const integrationID = provider.data.integrationID ?? scope.providerId + if (integrationID !== "openai") return null + recognized = true + const integration = await connection.client.integration.get({ integrationID, location }, { signal }) + if (integration.location.directory !== scope.directory || integration.data.id !== integrationID) throw new Error("Integration location changed") + const selected = integration.data.connections[0] + if (selected?.type !== "credential" || selected.method !== "oauth" || selected.status?.status === "needs_auth") { + throw new Error("Subscription OAuth unavailable") + } + // Available in 2.0.20+. A missing endpoint is feature-local unavailability, + // not a reason to raise the global minimum, read SQLite or try auth.json. + const entries = await connection.client.credential.list({ signal }).catch(error => { + if (error instanceof ClientError && error.reason === "UnexpectedStatus" + && "cause" in error && typeof error.cause === "object" && error.cause !== null + && "status" in error.cause && error.cause.status === 404) { + unavailableReason = "native-credential-api-unavailable" + } + throw error + }) + const entry = entries.find(entry => entry.id === selected.id && entry.integrationID === integrationID && entry.active) + const value = entry?.value + if (value?.type !== "oauth" || !["chatgpt-browser", "chatgpt-headless"].includes(value.methodID) + || !Number.isFinite(value.expires) || value.expires <= Date.now() + 120_000 || !getString(value.access)) { + throw new Error("Selected subscription credential unavailable") + } + const claims = decodeJwtClaims(value.access) + const jwtExpiry = Number(claims?.exp) * 1000 + if (Number.isFinite(jwtExpiry) && jwtExpiry <= Date.now() + 120_000) throw new Error("Expired subscription credential") + const accountID = getString(value.metadata?.accountID) + ?? getString(claims?.["https://api.openai.com/auth"]?.chatgpt_account_id) + // Require a same-selected-credential account identity, never another store. + if (!accountID) throw new Error("Subscription account unavailable") + const identity = createHash("sha256").update(JSON.stringify([ + integrationID, entry!.id, value.methodID, accountID, value.expires, value.access, + ])).digest("hex") + connection.assertCurrent() + signal.throwIfAborted() + return { identity, access: value.access, accountID } + } + + try { + const selected = await readSelected() + if (!selected) { caches.get(connection)?.delete(key); return null } + let cache = caches.get(connection) + if (!cache) { cache = new Map(); caches.set(connection, cache) } + let requests = pending.get(connection) + if (!requests) { requests = new Map(); pending.set(connection, requests) } + let snapshot = cache.get(key) + if (snapshot?.identity !== selected.identity || Date.now() - snapshot.fetchedAt >= CACHE_TTL_MS) { + cache.delete(key) + snapshot = undefined + } + if (!snapshot) { + requestKey = JSON.stringify([key, selected.identity]) + request = requests.get(requestKey) + if (!request) { + request = fetchQuota(selected.access, selected.accountID, signal) + requests.set(requestKey, request) + } + snapshot = { identity: selected.identity, usage: await request, fetchedAt: Date.now() } + } + // Warm snapshots and in-flight results both need fresh native selection. + // Never publish or populate a cache after an account or connection switch. + const current = await readSelected() + if (current?.identity !== selected.identity) { cache.delete(key); return response() } + connection.assertCurrent() + signal.throwIfAborted() + if (cache.size >= 128 && !cache.has(key)) cache.delete(cache.keys().next().value!) + cache.set(key, snapshot) + return response(snapshot) + } catch { + caches.get(connection)?.delete(key) + // Native errors may contain credential values, labels or request bodies. + // Neither public responses nor logs receive any upstream error detail. + return recognized ? response() : null + } finally { + // Keep the quota promise shared until the snapshot has been revalidated + // and published, not just until its provider HTTP response arrives. + const requests = pending.get(connection) + if (requestKey && requests && requests.get(requestKey) === request) requests.delete(requestKey) + } + } +} + +async function fetchQuota(access: string, accountID: string, signal: AbortSignal): Promise { + const response = await fetch("https://chatgpt.com/backend-api/wham/usage", { + signal, redirect: "error", headers: { Authorization: `Bearer ${access}`, "ChatGPT-Account-Id": accountID, "Content-Type": "application/json" }, + }) + if (!response.ok || !response.body) { + await response.body?.cancel().catch(() => {}) + throw new Error("Quota unavailable") + } + const reader = response.body.getReader() + const abort = () => { void reader.cancel().catch(() => {}) } + signal.addEventListener("abort", abort, { once: true }) + const chunks: Uint8Array[] = [] + let size = 0 + try { + while (true) { + signal.throwIfAborted() + const chunk = await reader.read() + if (chunk.done) break + size += chunk.value.byteLength + if (size > MAX_RESPONSE_BYTES) throw new Error("Quota response too large") + chunks.push(chunk.value) + } + signal.throwIfAborted() + return parseCodexUsage(JSON.parse(Buffer.concat(chunks).toString("utf8"))) + } finally { + signal.removeEventListener("abort", abort) + await reader.cancel().catch(() => {}) + reader.releaseLock() + } +} diff --git a/packages/server/src/usage/providers/oauth.test.ts b/packages/server/src/usage/providers/oauth.test.ts index 05cfc3aef..fbd1caf09 100644 --- a/packages/server/src/usage/providers/oauth.test.ts +++ b/packages/server/src/usage/providers/oauth.test.ts @@ -3,7 +3,6 @@ import fs from "node:fs" import os from "node:os" import path from "node:path" import test from "node:test" - import { oauthProviders, parseCodexUsage } from "./oauth" test("parses Codex rate limits, credits, and business spend limits", () => { @@ -12,7 +11,6 @@ test("parses Codex rate limits, credits, and business spend limits", () => { credits: { balance: 12.5, unlimited: false }, spend_control: { individual_limit: { used: 10, limit: 100, used_percent: 10 } }, }) - assert.equal(usage.windows["5h"].usedPercent, 25) assert.equal(usage.windows.credits_balance.valueLabel, "$12.50") assert.equal(usage.windows.credits.valueLabel, "10 / 100 used") @@ -23,139 +21,23 @@ test("rejects empty and malformed Codex usage payloads", () => { assert.throws(() => parseCodexUsage({ rate_limit: { primary_window: { used_percent: "invalid" } } }), /no quota data/) }) -test("does not refresh or mutate expired OpenCode credentials", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-codex-")) - const authFile = path.join(directory, "auth.json") - const previousAuthFile = process.env.OPENCODE_AUTH_FILE - const previousCodexHome = process.env.CODEX_HOME +test("unscoped Codex requests never read or fall back to host legacy/CLI credentials", async () => { + const previousRead = fs.readFileSync const previousFetch = globalThis.fetch - fs.writeFileSync(authFile, JSON.stringify({ - openai: { type: "oauth", access: "expired", refresh: "refresh-token", expires: 1, accountId: "account" }, - })) - process.env.OPENCODE_AUTH_FILE = authFile - process.env.CODEX_HOME = path.join(directory, "missing-codex-home") - const before = fs.readFileSync(authFile, "utf8") - globalThis.fetch = async () => { throw new Error("expired credentials must not make requests") } - + let reads = 0 + let calls = 0 + fs.readFileSync = (() => { + reads++ + return JSON.stringify({ openai: { type: "oauth", access: "legacy", expires: Date.now() + 3600000 }, + tokens: { access_token: "cli" } }) + }) as unknown as typeof fs.readFileSync + globalThis.fetch = async () => { calls++; return Response.json({ rate_limit: { primary_window: { used_percent: 5 } } }) } try { - const usage = await oauthProviders.find((provider) => provider.id === "codex")!.fetchQuota() + const usage = await oauthProviders.find(provider => provider.id === "codex")!.fetchQuota() assert.equal(usage.ok, false) - assert.match(usage.error ?? "", /Reconnect it in OpenCode or Codex CLI/) - assert.equal(fs.readFileSync(authFile, "utf8"), before) - } finally { - globalThis.fetch = previousFetch - if (previousAuthFile === undefined) delete process.env.OPENCODE_AUTH_FILE - else process.env.OPENCODE_AUTH_FILE = previousAuthFile - if (previousCodexHome === undefined) delete process.env.CODEX_HOME - else process.env.CODEX_HOME = previousCodexHome - fs.rmSync(directory, { recursive: true, force: true }) - } -}) - -test("uses a valid Codex CLI session before stale OpenCode OAuth", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-codex-cli-")) - const openCodeAuthFile = path.join(directory, "opencode-auth.json") - const codexHome = path.join(directory, "codex") - const previousAuthFile = process.env.OPENCODE_AUTH_FILE - const previousCodexHome = process.env.CODEX_HOME - const previousFetch = globalThis.fetch - const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url") - fs.mkdirSync(codexHome) - fs.writeFileSync(openCodeAuthFile, JSON.stringify({ - openai: { type: "oauth", access: "expired", refresh: "stale-refresh", expires: 1 }, - })) - fs.writeFileSync(path.join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: `header.${payload}.signature`, refresh_token: "codex-refresh", account_id: "account" }, - })) - process.env.OPENCODE_AUTH_FILE = openCodeAuthFile - process.env.CODEX_HOME = codexHome - globalThis.fetch = async (input, init) => { - assert.equal(String(input), "https://chatgpt.com/backend-api/wham/usage") - assert.equal(new Headers(init?.headers).get("authorization")?.endsWith(`header.${payload}.signature`), true) - return Response.json({ rate_limit: { primary_window: { used_percent: 0, reset_at: 2_000_000_000 } } }) - } - - try { - const usage = await oauthProviders.find((provider) => provider.id === "codex")!.fetchQuota() - assert.equal(usage.ok, true) - } finally { - globalThis.fetch = previousFetch - if (previousAuthFile === undefined) delete process.env.OPENCODE_AUTH_FILE - else process.env.OPENCODE_AUTH_FILE = previousAuthFile - if (previousCodexHome === undefined) delete process.env.CODEX_HOME - else process.env.CODEX_HOME = previousCodexHome - fs.rmSync(directory, { recursive: true, force: true }) - } -}) - -test("tries valid Codex CLI credentials after OpenCode authentication fails without mutating either store", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-codex-fallback-")) - const openCodeAuthFile = path.join(directory, "opencode-auth.json") - const codexHome = path.join(directory, "codex") - const codexAuthFile = path.join(codexHome, "auth.json") - const previousAuthFile = process.env.OPENCODE_AUTH_FILE - const previousCodexHome = process.env.CODEX_HOME - const previousFetch = globalThis.fetch - const validPayload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url") - fs.mkdirSync(codexHome) - fs.writeFileSync(openCodeAuthFile, JSON.stringify({ - codex: { type: "oauth", access: "open-code-access", refresh: "open-code-refresh", expires: Date.now() + 3_600_000 }, - })) - fs.writeFileSync(codexAuthFile, JSON.stringify({ - tokens: { access_token: `header.${validPayload}.signature`, refresh_token: "cli-refresh", account_id: "account" }, - })) - process.env.OPENCODE_AUTH_FILE = openCodeAuthFile - process.env.CODEX_HOME = codexHome - globalThis.fetch = async (input, init) => { - const authorization = new Headers(init?.headers).get("authorization") - if (authorization === "Bearer open-code-access") return new Response(null, { status: 401 }) - assert.equal(authorization, `Bearer header.${validPayload}.signature`) - return Response.json({ rate_limit: { primary_window: { used_percent: 0 } } }) - } - - try { - const usage = await oauthProviders.find((provider) => provider.id === "codex")!.fetchQuota() - assert.equal(usage.ok, true) - assert.equal((JSON.parse(fs.readFileSync(openCodeAuthFile, "utf8")).codex as Record).refresh, "open-code-refresh") - assert.equal((JSON.parse(fs.readFileSync(codexAuthFile, "utf8")).tokens as Record).refresh_token, "cli-refresh") - } finally { - globalThis.fetch = previousFetch - if (previousAuthFile === undefined) delete process.env.OPENCODE_AUTH_FILE - else process.env.OPENCODE_AUTH_FILE = previousAuthFile - if (previousCodexHome === undefined) delete process.env.CODEX_HOME - else process.env.CODEX_HOME = previousCodexHome - fs.rmSync(directory, { recursive: true, force: true }) - } -}) - -test("skips an API-key OpenAI alias for a valid Codex OAuth alias", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-codex-alias-")) - const openCodeAuthFile = path.join(directory, "opencode-auth.json") - const previousAuthFile = process.env.OPENCODE_AUTH_FILE - const previousCodexHome = process.env.CODEX_HOME - const previousFetch = globalThis.fetch - fs.writeFileSync(openCodeAuthFile, JSON.stringify({ - openai: { type: "api", key: "api-key" }, - codex: { type: "oauth", access: "codex-access", expires: Date.now() + 3_600_000 }, - })) - process.env.OPENCODE_AUTH_FILE = openCodeAuthFile - process.env.CODEX_HOME = path.join(directory, "missing-codex-home") - globalThis.fetch = async (_input, init) => { - assert.equal(new Headers(init?.headers).get("authorization"), "Bearer codex-access") - return Response.json({ rate_limit: { primary_window: { used_percent: 0 } } }) - } - - try { - const usage = await oauthProviders.find((provider) => provider.id === "codex")!.fetchQuota() - assert.equal(usage.ok, true) - } finally { - globalThis.fetch = previousFetch - if (previousAuthFile === undefined) delete process.env.OPENCODE_AUTH_FILE - else process.env.OPENCODE_AUTH_FILE = previousAuthFile - if (previousCodexHome === undefined) delete process.env.CODEX_HOME - else process.env.CODEX_HOME = previousCodexHome - fs.rmSync(directory, { recursive: true, force: true }) - } + assert.equal(reads, 0) + assert.equal(calls, 0) + } finally { fs.readFileSync = previousRead; globalThis.fetch = previousFetch } }) test("rejects empty and malformed Copilot usage payloads after OAuth alias fallback", async () => { @@ -163,19 +45,15 @@ test("rejects empty and malformed Copilot usage payloads after OAuth alias fallb const authFile = path.join(directory, "auth.json") const previousAuthFile = process.env.OPENCODE_AUTH_FILE const previousFetch = globalThis.fetch - fs.writeFileSync(authFile, JSON.stringify({ - "github-copilot": { type: "api", key: "api-key" }, - copilot: { type: "oauth", access: "copilot-access" }, - })) + fs.writeFileSync(authFile, JSON.stringify({ "github-copilot": { type: "api", key: "api-key" }, copilot: { type: "oauth", access: "copilot-access" } })) process.env.OPENCODE_AUTH_FILE = authFile - try { for (const payload of [{}, { quota_snapshots: { chat: { entitlement: "invalid", remaining: 1 } } }]) { globalThis.fetch = async (_input, init) => { assert.equal(new Headers(init?.headers).get("authorization"), "token copilot-access") return Response.json(payload) } - const usage = await oauthProviders.find((provider) => provider.id === "github-copilot")!.fetchQuota() + const usage = await oauthProviders.find(provider => provider.id === "github-copilot")!.fetchQuota() assert.equal(usage.ok, false) assert.match(usage.error ?? "", /no quota data/) } diff --git a/packages/server/src/usage/providers/oauth.ts b/packages/server/src/usage/providers/oauth.ts index 50ba36ae4..3b57b8276 100644 --- a/packages/server/src/usage/providers/oauth.ts +++ b/packages/server/src/usage/providers/oauth.ts @@ -1,40 +1,15 @@ -import fs from "node:fs" -import os from "node:os" -import path from "node:path" - import type { UsageProvider } from "../types" import { - decodeJwtClaims, fetchJson, - getCredential, getOAuthEntry, getString, notConfigured, - oauthTokenNeedsRefresh, resolveWindowLabel, safeFetch, toNumber, toTimestamp, toUsageWindow, } from "../shared" -import type { AuthEntry } from "../types" - -const CODEX_REAUTH_ERROR = "Codex session expired. Reconnect it in OpenCode or Codex CLI." - -function getCodexCliAuthEntry(): AuthEntry | null { - const file = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "auth.json") - try { - const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { tokens?: Record } - const tokens = parsed.tokens - if (!tokens || typeof tokens !== "object") return null - const access = getString(tokens.access_token) - const refresh = getString(tokens.refresh_token) - if (!access && !refresh) return null - return { type: "oauth", access, refresh, accountId: getString(tokens.account_id) } - } catch { - return null - } -} export function parseCodexUsage(payload: any) { const windows: Record> = {} @@ -76,43 +51,9 @@ const codex: UsageProvider = { name: "Codex", aliases: codexAliases, async fetchQuota() { - const entries: AuthEntry[] = [] - const openCode = getOAuthEntry(codexAliases) - if (openCode) entries.push(openCode) - const codexCli = getCodexCliAuthEntry() - if (codexCli) entries.push(codexCli) - if (!entries.length) return notConfigured(this.id, this.name) - entries.sort((left, right) => - Number(oauthTokenNeedsRefresh(left)) - Number(oauthTokenNeedsRefresh(right))) - return safeFetch(this.id, this.name, async () => { - let lastError: unknown - for (const [index, entry] of entries.entries()) { - if (oauthTokenNeedsRefresh(entry)) { - lastError = new Error(CODEX_REAUTH_ERROR) - continue - } - const token = getString(entry.access) ?? getString(entry.token) - if (!token) { - lastError = new Error("OpenAI OAuth entry has no access token") - continue - } - try { - const accountId = getString(entry.accountId) ?? getString(decodeJwtClaims(token)?.["https://api.openai.com/auth"]?.chatgpt_account_id) - const payload = await fetchJson("https://chatgpt.com/backend-api/wham/usage", { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), - }, - }) - return parseCodexUsage(payload) - } catch (error) { - lastError = error - if (index === entries.length - 1 || !(error instanceof Error) || !/HTTP (401|403)\b/.test(error.message)) throw error - } - } - throw lastError instanceof Error ? lastError : new Error("OpenAI OAuth credentials failed") - }) + // Subscription usage requires an owned session and selected native connection. + // The route uses native-codex.ts; unscoped callers must never read host auth. + return notConfigured(this.id, this.name) }, } diff --git a/packages/server/src/workspaces/git-history.test.ts b/packages/server/src/workspaces/git-history.test.ts new file mode 100644 index 000000000..27d31bc0b --- /dev/null +++ b/packages/server/src/workspaces/git-history.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { execFileSync } from "node:child_process" +import { mkdtemp, writeFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { getGitHistory, getGitCommit, getGitCommitDiff } from "./git-history" + +test("history reads real root, renamed, deleted, binary and merge files without changing HEAD", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "git-history-")) + const git = (...args: string[]) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", windowsHide: true }).trim() + const commit = (message: string) => { git("add", "."); git("commit", "-m", message); return git("rev-parse", "HEAD") } + try { + git("init", "-b", "main") + git("config", "user.name", "History Fixture") + git("config", "user.email", "fixture@example.invalid") + git("config", "core.autocrlf", "false") + assert.deepEqual((await getGitHistory(root)).commits, []) + await writeFile(path.join(root, "before name.txt"), "before\n") + const first = commit("Initial history") + assert.equal((await getGitHistory(root)).branch, "main") + assert.deepEqual(await getGitCommitDiff(root, first, "before name.txt"), { path: "before name.txt", before: "", after: "before\n", isBinary: false }) + git("mv", "before name.txt", "after name.txt") + const renamed = commit("Rename file") + assert.deepEqual((await getGitCommit(root, renamed)).files, [{ path: "after name.txt", originalPath: "before name.txt", status: "R" }]) + assert.equal((await getGitCommitDiff(root, renamed, "after name.txt")).before, "before\n") + git("rm", "after name.txt") + await writeFile(path.join(root, "binary.dat"), Buffer.from([0, 1, 2])) + const deleted = commit("Delete text and add binary") + assert.equal((await getGitCommitDiff(root, deleted, "after name.txt")).after, "") + assert.equal((await getGitCommitDiff(root, deleted, "binary.dat")).isBinary, true) + await assert.rejects(getGitCommitDiff(root, deleted, "../outside"), /does not belong/) + await assert.rejects(getGitCommit(root, "--output=escape"), /Invalid commit/) + const blob = git("rev-parse", `${deleted}:binary.dat`) + await assert.rejects(getGitCommit(root, blob), /not a commit/) + git("checkout", "-b", "side") + await writeFile(path.join(root, "side.txt"), "side\n") + commit("Side branch") + git("checkout", "main") + git("merge", "--no-ff", "side", "-m", "Merge side") + const head = git("rev-parse", "HEAD") + const history = await getGitHistory(root) + assert.equal(history.commits[0]?.id, head) + assert.equal(history.commits[0]?.parents.length, 2) + assert.equal((await getGitCommit(root, head)).parent, deleted) + assert.equal((await getGitCommitDiff(root, head, "side.txt")).after, "side\n") + assert.equal(git("rev-parse", "HEAD"), head) + assert.equal(git("status", "--porcelain"), "") + git("update-index", "--add", "--cacheinfo", `160000,${first},nested-repository`) + git("commit", "-m", "Add submodule reference") + const submodule = await getGitCommitDiff(root, git("rev-parse", "HEAD"), "nested-repository") + assert.equal(submodule.after, `Subproject commit ${first}\n`) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test("history pages retain their original HEAD when a new commit arrives", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "git-history-pages-")) + const git = (...args: string[]) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", windowsHide: true }).trim() + try { + git("init", "-b", "main") + git("config", "user.name", "History Fixture") + git("config", "user.email", "fixture@example.invalid") + for (let i = 0; i < 52; i++) git("commit", "--allow-empty", "-m", `Commit ${i}`) + const first = await getGitHistory(root) + assert.equal(first.commits.length, 50) + assert.equal(first.hasMore, true) + git("commit", "--allow-empty", "-m", "New head") + const second = await getGitHistory(root, 50, first.head!) + assert.equal(second.commits.length, 2) + assert.equal(second.commits[1]?.subject, "Commit 0") + assert.equal(second.hasMore, false) + assert.equal(new Set([...first.commits, ...second.commits].map(commit => commit.id)).size, 52) + await assert.rejects(getGitHistory(root, -1), /Invalid history/) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/packages/server/src/workspaces/git-history.ts b/packages/server/src/workspaces/git-history.ts new file mode 100644 index 000000000..352524c47 --- /dev/null +++ b/packages/server/src/workspaces/git-history.ts @@ -0,0 +1,86 @@ +import type { GitCommitDetails, GitCommitFile, GitHistoryPage, GitCommitDiff } from "../git-history-types" +import { runGitProcess } from "./git-process" +import { gitImageMime } from "./git-image-preview" + +const git = (directory: string, args: string[]) => runGitProcess(directory, args, { timeout: 15_000, maxBuffer: 2 * 1024 * 1024, priority: "foreground" }) + +function commitId(value: string): string { + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)) throw new Error("Invalid commit ID") + return value +} + +async function headId(directory: string): Promise { + try { return (await git(directory, ["rev-parse", "--verify", "--quiet", "HEAD"])).trim() } + catch (error) { + if ((error as { code?: number }).code === 1) return null // Unborn branch. + throw error + } +} + +export async function getGitHistory(directory: string, offset = 0, snapshot?: string): Promise { + if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid history offset") + const head = snapshot ? commitId(snapshot) : await headId(directory) + const branch = (await git(directory, ["branch", "--show-current"])).trim() || null + if (!head) return { head, branch, commits: [], hasMore: false } + const output = await git(directory, ["log", "-z", "--format=%H%x00%P%x00%an%x00%aI%x00%s%x00%D", "--max-count=51", `--skip=${offset}`, head, "--"]) + const fields = output.split("\0") + const commits = [] + for (let i = 0; i + 5 < fields.length; i += 6) { + commits.push({ id: fields[i]!, parents: fields[i + 1]!.split(" ").filter(Boolean), author: fields[i + 2]!, + date: fields[i + 3]!, subject: fields[i + 4]!, refs: fields[i + 5]! }) + } + return { head, branch, commits: commits.slice(0, 50), hasMore: commits.length > 50 } +} + +export async function getGitCommit(directory: string, revision: string): Promise { + const id = commitId(revision) + // Validate the object as a commit and read its metadata in one bounded process. + const metadata = await git(directory, ["show", "-s", "--format=%P%x00%B", `${id}^{commit}`, "--"]) + .catch(cause => { throw Object.assign(new Error("Object is not a commit"), { cause }) }) + const separator = metadata.indexOf("\0") + const parents = metadata.slice(0, separator).trim().split(" ").filter(Boolean) + const parent = parents[0] ? commitId(parents[0]) : null + const output = await git(directory, ["diff-tree", "--root", "--no-commit-id", "-r", "-M", "--name-status", "-z", ...(parent ? [parent, id] : [id]), "--"]) + const tokens = output.split("\0") + const files: GitCommitFile[] = [] + for (let i = 0; i < tokens.length && tokens[i];) { + const status = tokens[i++]! + const first = tokens[i++]! + const renamed = status.startsWith("R") || status.startsWith("C") + const path = renamed ? tokens[i++]! : first + files.push({ path, originalPath: renamed ? first : null, status: status[0]! }) + } + const message = metadata.slice(separator + 1).trimEnd() + return { id, parent, message, files } +} + +export async function getGitCommitDiff(directory: string, revision: string, filePath: string): Promise { + const commit = await getGitCommit(directory, revision) + // Authoritative file membership prevents arbitrary revision/path expressions. + const file = commit.files.find(file => file.path === filePath) + if (!file) throw new Error("File does not belong to this commit") + const mime = gitImageMime(file.path) + if (mime) { + const [before, after] = await Promise.all([ + !commit.parent || file.status === "A" ? null : readCommitFile(directory, commit.parent, file.originalPath ?? file.path, "base64"), + file.status === "D" ? null : readCommitFile(directory, commit.id, file.path, "base64"), + ]) + return { path: file.path, before: "", after: "", isBinary: true, image: { mime, before, after } } + } + const [before, after] = await Promise.all([ + !commit.parent || file.status === "A" ? "" : readCommitFile(directory, commit.parent, file.originalPath ?? file.path), + file.status === "D" ? "" : readCommitFile(directory, commit.id, file.path), + ]) + const isBinary = before.includes("\0") || after.includes("\0") + return { path: file.path, before: isBinary ? "" : before, after: isBinary ? "" : after, isBinary } +} + +async function readCommitFile(directory: string, revision: string, filePath: string, encoding: "utf8" | "base64" = "utf8"): Promise { + const entry = await git(directory, ["ls-tree", "-z", revision, "--", `:(literal)${filePath}`]) + const match = /^(\d{6}) (blob|commit) ([a-f0-9]{40}|[a-f0-9]{64})\t/.exec(entry) + if (!match) throw new Error("Commit file is unavailable") + // Gitlinks point to another repository; never read a submodule's working tree + // or mistake its commit object/message for the file's contents. + if (match[1] === "160000") return `Subproject commit ${match[3]}\n` + return runGitProcess(directory, ["cat-file", "blob", match[3]!], { encoding, timeout: 15_000, maxBuffer: (encoding === "base64" ? 5 : 2) * 1024 * 1024, priority: "foreground" }) +} diff --git a/packages/server/src/workspaces/git-image-preview.test.ts b/packages/server/src/workspaces/git-image-preview.test.ts new file mode 100644 index 000000000..a5d0c66de --- /dev/null +++ b/packages/server/src/workspaces/git-image-preview.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { execFileSync } from "node:child_process" +import { mkdtemp, writeFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { getWorktreeGitDiff } from "./git-status" +import { getGitCommitDiff } from "./git-history" +import { runGitProcess } from "./git-process" + +test("image previews retain exact HEAD/index/worktree and historical bytes with bounded reads", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "git-image-preview-")) + const git = (...args: string[]) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", windowsHide: true }).trim() + const initial = Buffer.from([137, 80, 78, 71, 0, 13, 10, 255, 128]) + const indexed = Buffer.from([...initial, 1]) + const working = Buffer.from([...indexed, 2]) + const diff = (scope: "staged" | "unstaged", file = "image.png") => getWorktreeGitDiff({ workspaceFolder: root, path: file, scope }) + try { + git("init", "-b", "main") + git("config", "user.name", "Image Fixture") + git("config", "user.email", "fixture@example.invalid") + git("config", "core.autocrlf", "false") + await writeFile(path.join(root, "image.png"), initial) + assert.deepEqual((await diff("unstaged")).image, { mime: "image/png", before: null, after: initial.toString("base64") }) + git("add", ".") + git("commit", "-m", "Initial image") + const first = git("rev-parse", "HEAD") + assert.deepEqual((await getGitCommitDiff(root, first, "image.png")).image, { mime: "image/png", before: null, after: initial.toString("base64") }) + await writeFile(path.join(root, "image.png"), indexed) + git("add", ".") + await writeFile(path.join(root, "image.png"), working) + assert.deepEqual((await diff("staged")).image, { mime: "image/png", before: initial.toString("base64"), after: indexed.toString("base64") }) + assert.deepEqual((await diff("unstaged")).image, { mime: "image/png", before: indexed.toString("base64"), after: working.toString("base64") }) + git("commit", "-m", "Indexed image only") + const second = git("rev-parse", "HEAD") + assert.deepEqual((await getGitCommitDiff(root, second, "image.png")).image, { mime: "image/png", before: initial.toString("base64"), after: indexed.toString("base64") }) + await assert.rejects(getGitCommitDiff(root, second, "outside.png"), /does not belong/) + git("add", ".") + git("commit", "-m", "Working image") + git("mv", "image.png", "renamed.png") + git("commit", "-m", "Rename image") + const renamed = await getGitCommitDiff(root, git("rev-parse", "HEAD"), "renamed.png") + assert.equal(renamed.image?.before, working.toString("base64")) + assert.equal(renamed.image?.after, working.toString("base64")) + git("rm", "renamed.png") + assert.equal((await diff("staged", "renamed.png")).image?.after, null) + git("commit", "-m", "Delete image") + assert.equal((await getGitCommitDiff(root, git("rev-parse", "HEAD"), "renamed.png")).image?.after, null) + await writeFile(path.join(root, "large.png"), Buffer.alloc(6 * 1024 * 1024)) + await assert.rejects(diff("unstaged", "large.png"), /too large/) + git("add", "large.png") + await assert.rejects(runGitProcess(root, ["cat-file", "blob", ":large.png"], { encoding: "base64", maxBuffer: 1024 }), /maxBuffer/) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/packages/server/src/workspaces/git-image-preview.ts b/packages/server/src/workspaces/git-image-preview.ts new file mode 100644 index 000000000..09fef8dec --- /dev/null +++ b/packages/server/src/workspaces/git-image-preview.ts @@ -0,0 +1,37 @@ +import { realpath } from "node:fs/promises" +import path from "node:path" +import { FileSystemBrowser } from "../filesystem/browser" +import { runGitProcess } from "./git-process" + +const imageTypes: Record = { + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", + gif: "image/gif", svg: "image/svg+xml", ico: "image/x-icon", bmp: "image/bmp", avif: "image/avif", +} +export const gitImageMime = (filePath: string) => imageTypes[path.extname(filePath).slice(1).toLowerCase()] + +export async function readGitImageBlob(directory: string, object: string): Promise { + try { + return await runGitProcess(directory, ["cat-file", "blob", object], { + encoding: "base64", maxBuffer: 5 * 1024 * 1024, timeout: 15_000, priority: "foreground", + }) + } catch (error) { + // Known HEAD/index paths may be absent for additions, deletions or unborn HEAD. + // Size limits and process failures remain errors, not missing images. + const failure = error as { code?: unknown; stderr?: string } + if (failure.code === 128 && /does not exist|not in|invalid object name|not a valid object name|could not get object info/i.test(failure.stderr ?? "")) return null + throw error + } +} + +export async function readWorktreeImage(directory: string, filePath: string): Promise { + try { + const root = await realpath(directory) + const resolved = await realpath(path.join(root, filePath)) + const relative = path.relative(root, resolved) + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error("Image is outside the worktree") + return (await new FileSystemBrowser({ rootDir: root }).readFileContent(relative, { encoding: "base64" })).contents + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null + throw error + } +} diff --git a/packages/server/src/workspaces/git-process.ts b/packages/server/src/workspaces/git-process.ts index 6d0824bbc..8f003140c 100644 --- a/packages/server/src/workspaces/git-process.ts +++ b/packages/server/src/workspaces/git-process.ts @@ -1,6 +1,6 @@ import { Worker } from "node:worker_threads" -interface GitProcessOptions { timeout?: number; maxBuffer?: number; priority?: "foreground" | "background" } +interface GitProcessOptions { timeout?: number; maxBuffer?: number; priority?: "foreground" | "background"; encoding?: "utf8" | "base64" } interface GitRequest extends GitProcessOptions { id: number; directory: string; args: string[]; env: NodeJS.ProcessEnv } interface GitResponse { id: number; stdout: string; stderr: string; error?: { message: string; code?: string | number | null } } @@ -18,10 +18,10 @@ function workerMain() { const request = (foreground.shift() ?? queue.shift())! running += 1 execFile("git", ["-C", request.directory, ...request.args], { - encoding: "utf8", windowsHide: true, maxBuffer: request.maxBuffer ?? 1024 * 1024, + encoding: "buffer", windowsHide: true, maxBuffer: request.maxBuffer ?? 1024 * 1024, env: request.env, timeout: request.timeout, }, (error, stdout, stderr) => { - parentPort!.postMessage({ id: request.id, stdout, stderr, + parentPort!.postMessage({ id: request.id, stdout: stdout.toString(request.encoding ?? "utf8"), stderr: stderr.toString("utf8"), ...(error ? { error: { message: error.message, code: error.code } } : {}), } satisfies GitResponse) running -= 1 diff --git a/packages/server/src/workspaces/git-status.ts b/packages/server/src/workspaces/git-status.ts index 2dea3ceeb..a5bea9f4c 100644 --- a/packages/server/src/workspaces/git-status.ts +++ b/packages/server/src/workspaces/git-status.ts @@ -5,6 +5,7 @@ import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, Work import type { LogLike } from "./git-worktrees" import { normalizeGitWorktreeRelativePath } from "./git-mutations" import { runGitProcess } from "./git-process" +import { gitImageMime, readGitImageBlob, readWorktreeImage } from "./git-image-preview" type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } type GitSuccessResult = Extract @@ -24,11 +25,11 @@ async function readGitBlobAsDiffText(resultPromise: Promise, missingO return result.stdout } -async function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise { +async function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0], priority: "foreground" | "background" = "background"): Promise { try { // Preserve the previous streaming reader's unrestricted content size. Process // creation belongs to the worker, including per-untracked-file numstat reads. - return { ok: true, stdout: await runGitProcess(cwd, args, { maxBuffer: Infinity }) } + return { ok: true, stdout: await runGitProcess(cwd, args, { maxBuffer: Infinity, priority }) } } catch (cause) { const result = cause as Error & { code?: string | number; stdout?: string; stderr?: string } const stdout = result.stdout ?? "", stderr = result.stderr ?? "" @@ -271,7 +272,7 @@ function decodeGitShowResult(result: GitResult, missingOk = false): string { } async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise { - return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder) + return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder, [0], "foreground") } async function getTrackedDiffMetadata(params: { @@ -290,7 +291,7 @@ async function getTrackedDiffMetadata(params: { args.push(params.normalizedOriginalPath) } - const result = await runGit(args, params.workspaceFolder) + const result = await runGit(args, params.workspaceFolder, [0], "foreground") if (!result.ok) { throw result.error } @@ -304,7 +305,7 @@ async function getUntrackedDiffMetadata(params: { normalizedPath: string }): Promise<{ isBinary: boolean }> { const absolutePath = path.join(params.workspaceFolder, params.normalizedPath) - const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1]) + const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1], "foreground") if (!result.ok) { throw result.error } @@ -348,6 +349,20 @@ export async function getWorktreeGitDiff(params: { }) : trackedMetadata + const mime = gitImageMime(normalizedPath) + if (mime) { + let before = await readGitImageBlob(params.workspaceFolder, params.scope === "staged" + ? `HEAD:${normalizedOriginalPath ?? normalizedPath}` : `:${normalizedPath}`) + if (before === null && params.scope === "unstaged" && normalizedOriginalPath) { + before = await readGitImageBlob(params.workspaceFolder, `:${normalizedOriginalPath}`) + } + const after = params.scope === "staged" + ? await readGitImageBlob(params.workspaceFolder, `:${normalizedPath}`) + : await readWorktreeImage(params.workspaceFolder, normalizedPath) + return { path: normalizedPath, originalPath: normalizedOriginalPath, scope: params.scope, + before: "", after: "", isBinary: true, image: { mime, before, after } } + } + if (diffMetadata.isBinary) { return { path: normalizedPath, @@ -361,7 +376,7 @@ export async function getWorktreeGitDiff(params: { if (params.scope === "staged") { const [beforeResult, afterResult] = await Promise.all([ - readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true), + readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder, [0], "foreground"), true), readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true), ]) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 37c7359f6..a29572b67 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -492,6 +492,12 @@ export class WorkspaceManager { } } + async previewFile(workspaceId: string, relativePath: string, directory?: string): Promise { + const browser = new FileSystemBrowser({ rootDir: await this.fileBrowserRoot(workspaceId, directory) }) + const result = await browser.readFileContent(relativePath, { encoding: "base64" }) + return { workspaceId, relativePath, contents: result.contents, encoding: "base64" } + } + async readFileInDirectory(workspaceId: string, directory: string, relativePath: string, options?: { encoding?: "utf-8" | "base64" }): Promise { this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: directory }) @@ -512,8 +518,7 @@ export class WorkspaceManager { } async writeFileInDirectory(workspaceId: string, directory: string, relativePath: string, contents: string): Promise { - this.requireWorkspace(workspaceId) - const browser = new FileSystemBrowser({ rootDir: directory }) + const browser = new FileSystemBrowser({ rootDir: await this.fileBrowserRoot(workspaceId, directory) }) await browser.writeFile(relativePath, contents) } diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 15d496483..5385f4d9d 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -495,6 +495,13 @@ dependencies = [ "cc", ] +[[package]] +name = "codenomad-macos-window-fixture" +version = "0.1.0" +dependencies = [ + "tao", +] + [[package]] name = "codenomad-tauri" version = "0.20.0" diff --git a/packages/tauri-app/Cargo.toml b/packages/tauri-app/Cargo.toml index 87c1a103b..eee31246b 100644 --- a/packages/tauri-app/Cargo.toml +++ b/packages/tauri-app/Cargo.toml @@ -1,9 +1,10 @@ [workspace] -members = ["src-tauri", "tests/windows-input"] +members = ["src-tauri", "tests/windows-input", "tests/macos-window"] default-members = ["src-tauri"] resolver = "2" -# Tao 0.34.x lacks the upstream Windows input reentrancy fix (tao#1215). +# Tao 0.34.x lacks Windows input reentrancy (tao#1215) and macOS +# read-only zoom detection (tao#1182) fixes. # Keep the current Tauri API/dependency line; see vendor/README.md for provenance. [patch.crates-io] tao = { path = "vendor/tao-0.34.6" } diff --git a/packages/tauri-app/src-tauri/build.rs b/packages/tauri-app/src-tauri/build.rs index e9f11dbc7..710c1c8ea 100644 --- a/packages/tauri-app/src-tauri/build.rs +++ b/packages/tauri-app/src-tauri/build.rs @@ -3,6 +3,18 @@ fn main() { 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); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + // Cargo examples do not inherit the application executable's resource + // manifest. The isolated native fixture also needs common-controls v6 + // (named subclass exports) and the production per-monitor DPI context. + let fixture_manifest = manifest_path.join("examples/window_constraints_windows.manifest"); + println!("cargo:rerun-if-changed={}", fixture_manifest.display()); + println!("cargo:rustc-link-arg-examples=/MANIFEST:EMBED"); + println!( + "cargo:rustc-link-arg-examples=/MANIFESTINPUT:{}", + fixture_manifest.display() + ); + } let bundled_resources = std::path::Path::new(&out_dir) .ancestors() .nth(3) @@ -47,6 +59,7 @@ fn main() { "preferences_accept_request", "preferences_resolve_transition", "window_control", + "owned_webview_zoom", "popup_titlebar_menu", "open_remote_window", "client_state_claim_access", diff --git a/packages/tauri-app/src-tauri/capabilities/main-window.json b/packages/tauri-app/src-tauri/capabilities/main-window.json index 7fabedd9b..34e9cfb0b 100644 --- a/packages/tauri-app/src-tauri/capabilities/main-window.json +++ b/packages/tauri-app/src-tauri/capabilities/main-window.json @@ -24,7 +24,6 @@ "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", diff --git a/packages/tauri-app/src-tauri/capabilities/owned-webview-zoom.json b/packages/tauri-app/src-tauri/capabilities/owned-webview-zoom.json new file mode 100644 index 000000000..c94c9cb9b --- /dev/null +++ b/packages/tauri-app/src-tauri/capabilities/owned-webview-zoom.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/capabilities.json", + "identifier": "owned-webview-zoom", + "description": "Allow primary application webviews to request bounded relative zoom of themselves only; the host validates registered ownership and renderer origin.", + "local": true, + "remote": { "urls": ["http://*:*", "https://*:*"] }, + "webviews": ["local-*", "remote-*", "preferences"], + "permissions": ["allow-owned-webview-zoom"] +} diff --git a/packages/tauri-app/src-tauri/capabilities/preferences-window.json b/packages/tauri-app/src-tauri/capabilities/preferences-window.json index 24ec40b97..44b840924 100644 --- a/packages/tauri-app/src-tauri/capabilities/preferences-window.json +++ b/packages/tauri-app/src-tauri/capabilities/preferences-window.json @@ -6,7 +6,7 @@ "remote": { "urls": ["http://127.0.0.1:*", "http://localhost:*", "https://127.0.0.1:*", "https://localhost:*"] }, - "windows": ["preferences"], + "webviews": ["preferences"], "permissions": [ "core:event:allow-listen", "core:event:allow-unlisten", diff --git a/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json index 210c8d88b..d74a412b6 100644 --- a/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json +++ b/packages/tauri-app/src-tauri/capabilities/remote-window-notifications.json @@ -6,7 +6,7 @@ "remote": { "urls": ["http://*:*", "https://*:*"] }, - "windows": ["remote-*"], + "webviews": ["remote-*"], "permissions": [ "notification:allow-is-permission-granted", "notification:allow-request-permission", diff --git a/packages/tauri-app/src-tauri/examples/window_constraints_windows.manifest b/packages/tauri-app/src-tauri/examples/window_constraints_windows.manifest new file mode 100644 index 000000000..027739a88 --- /dev/null +++ b/packages/tauri-app/src-tauri/examples/window_constraints_windows.manifest @@ -0,0 +1,14 @@ + + + + + + + + + + true/pm + PerMonitorV2,PerMonitor + + + diff --git a/packages/tauri-app/src-tauri/examples/window_constraints_windows.rs b/packages/tauri-app/src-tauri/examples/window_constraints_windows.rs new file mode 100644 index 000000000..01cec6866 --- /dev/null +++ b/packages/tauri-app/src-tauri/examples/window_constraints_windows.rs @@ -0,0 +1,349 @@ +//! Isolated, real Tauri/Wry/WebView2 Windows regression. No backend, singleton, +//! application profile, network navigation, or shared OpenCode service is used. +//! Run: cargo run --example window_constraints_windows +//! Add -- --baseline to demonstrate the original state-changing native setter. + +use std::collections::HashMap; +use std::sync::Mutex; + +#[cfg(windows)] +static POSITION_CHANGES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +#[cfg(windows)] +unsafe extern "system" fn position_probe( + hwnd: windows_sys::Win32::Foundation::HWND, + message: u32, + wparam: usize, + lparam: isize, + _id: usize, + _data: usize, +) -> isize { + if message == windows_sys::Win32::UI::WindowsAndMessaging::WM_WINDOWPOSCHANGED { + POSITION_CHANGES.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + windows_sys::Win32::UI::Shell::DefSubclassProc(hwnd, message, wparam, lparam) +} + +#[cfg(windows)] +fn control_wheel(window: &tauri::Window) { + use windows_sys::Win32::{ + Foundation::{HWND, LPARAM}, + UI::WindowsAndMessaging::*, + }; + unsafe extern "system" fn find_renderer(hwnd: HWND, data: LPARAM) -> i32 { + let mut name = [0u16; 128]; + let length = GetClassNameW(hwnd, name.as_mut_ptr(), name.len() as i32); + if String::from_utf16_lossy(&name[..length as usize]) == "Chrome_RenderWidgetHostHWND" + && IsWindowVisible(hwnd) != 0 + { + *(data as *mut usize) = hwnd as usize; + return 0; + } + 1 + } + let hwnd = window.hwnd().unwrap().0 as usize; + window + .run_on_main_thread(move || unsafe { + let mut renderer = 0usize; + EnumChildWindows( + hwnd as HWND, + Some(find_renderer), + &mut renderer as *mut usize as isize, + ); + assert_ne!(renderer, 0, "native Chromium input target missing"); + let mut rect = std::mem::zeroed(); + assert_ne!(GetWindowRect(renderer as HWND, &mut rect), 0); + let point = (((rect.top + 50) as u32) << 16) | ((rect.left + 50) as u32 & 0xffff); + // Target only this isolated renderer HWND. No SendInput, global key + // state or user-window input is modified. + SendMessageW( + renderer as HWND, + WM_MOUSEWHEEL, + (120 << 16) | 0x0008, + point as isize, + ); + }) + .unwrap(); +} + +mod client_state { + pub const MIN_WINDOW_WIDTH: i32 = 390; +} +mod preferences_window { + pub const LABEL: &str = "preferences"; +} +struct AppState { + remote_zoom_levels: Mutex>, +} +mod window_constraints { + include!("../src/window_constraints.rs"); + + pub fn snapshot(window: &tauri::Window) -> Option<(f64, f64, f64)> { + use tauri::Manager; + let registry = window.state::(); + let entry = registry.0.lock().unwrap().get(window.label()).cloned()?; + let updates = entry.updates.lock().unwrap(); + let minimum = updates.applied?; + Some((updates.zoom, minimum.width, minimum.height)) + } +} + +#[cfg(not(windows))] +fn main() { + eprintln!("This fixture requires Windows"); + std::process::exit(1); +} + +#[cfg(windows)] +fn main() { + use std::thread; + use std::time::{Duration, Instant}; + use tauri::{LogicalSize, Manager, PhysicalPosition, WebviewUrl, WebviewWindowBuilder}; + use windows_sys::Win32::UI::WindowsAndMessaging::{GetWindowPlacement, WINDOWPLACEMENT}; + + fn wait(mut condition: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + while !condition() { + assert!( + Instant::now() < deadline, + "constraint/state transition timed out" + ); + thread::sleep(Duration::from_millis(20)); + } + } + fn normal_rect(window: &tauri::Window) -> (i32, i32, i32, i32) { + let hwnd = window.hwnd().unwrap().0; + let mut placement: WINDOWPLACEMENT = unsafe { std::mem::zeroed() }; + placement.length = std::mem::size_of::() as u32; + assert_ne!(unsafe { GetWindowPlacement(hwnd, &mut placement) }, 0); + let rect = placement.rcNormalPosition; + (rect.left, rect.top, rect.right, rect.bottom) + } + thread::spawn(|| { + thread::sleep(Duration::from_secs(60)); + eprintln!("FAIL: isolated constraint fixture watchdog expired"); + std::process::exit(3); + }); + let profile = tempfile::Builder::new() + .prefix("tauri-window-constraints-") + .tempdir_in(std::env::temp_dir().join("opencode")) + .unwrap(); + let baseline = std::env::args().any(|arg| arg == "--baseline"); + tauri::Builder::default() + .manage(window_constraints::WindowConstraints::default()) + .manage(AppState { + remote_zoom_levels: Mutex::new(HashMap::new()), + }) + .setup(move |app| { + let app = app.handle().clone(); + // Local uses the same registered constraints but a separate zoom + // persistence callback; remote exercises the actual COM zoom handler. + let local = WebviewWindowBuilder::new( + &app, + "local-fixture", + WebviewUrl::External("about:blank".parse().unwrap()), + ) + .data_directory(profile.path().join("local")) + .title("CodeNomad isolated constraint regression") + .inner_size(900.0, 700.0) + .min_inner_size(390.0, 600.0) + .decorations(false) + .build()?; + let remote = WebviewWindowBuilder::new( + &app, + "remote-fixture", + WebviewUrl::External("about:blank".parse().unwrap()), + ) + .data_directory(profile.path().join("remote")) + .title("CodeNomad isolated remote constraint regression") + .zoom_hotkeys_enabled(true) + .inner_size(900.0, 700.0) + .min_inner_size(390.0, 600.0) + .build()?; + window_constraints::register(&local.as_ref().window(), 1.0); + window_constraints::register(&remote.as_ref().window(), 1.0); + window_constraints::register_remote_zoom(&remote, &app); + remote.with_webview(|platform| { + let html = webview2_com::CoTaskMemPWSTR::from("Isolated zoom fixtureNative zoom regression"); + unsafe { platform.controller().CoreWebView2().unwrap() + .NavigateToString(*html.as_ref().as_pcwstr()).unwrap() }; + })?; + for window in [&local, &remote] { + assert_ne!(unsafe { windows_sys::Win32::UI::Shell::SetWindowSubclass( + window.hwnd().unwrap().0, Some(position_probe), 0x434e, 0, + ) }, 0); + } + let startup = WebviewWindowBuilder::new(&app, "startup-fixture", WebviewUrl::External("about:blank".parse().unwrap())) + .data_directory(profile.path().join("startup")) + .title("CodeNomad isolated maximized restore regression") + .inner_size(900.0, 700.0).maximized(true).build()?; + window_constraints::register(&startup.as_ref().window(), 5.0); + thread::spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + for webview in [&local, &remote] { + let window = webview.as_ref().window(); + wait(|| window_constraints::snapshot(&window).is_some()); + let normal = normal_rect(&window); + window.maximize().unwrap(); + wait(|| window.is_maximized().unwrap()); + if baseline { + window + .set_min_size(Some(LogicalSize::new(488.0, 750.0))) + .unwrap(); + wait(|| !window.is_maximized().unwrap()); + println!( + "BASELINE reproduced: set_min_size unmaximized {}", + window.label() + ); + break; + } + // Native callback and successive queued writes must all + // leave maximize/fullscreen and normal placement intact. + for zoom in [1.25, 3.0, 0.5, 2.0] { + webview.set_zoom(zoom).unwrap(); + // WebView2 explicitly does not fire its change event + // for an in-range programmatic setter. This mirrors + // the production menu/keyboard command path. + window_constraints::apply(&window, zoom); + } + thread::sleep(Duration::from_millis(800)); + if window.label() == "remote-fixture" { + // WebView2 suppresses user zoom input while another + // native window (the startup fixture) owns focus. + window.set_focus().unwrap(); + webview.with_webview(|platform| unsafe { + platform.controller().MoveFocus( + webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC, + ).unwrap(); + }).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + control_wheel(&window); + thread::sleep(Duration::from_millis(250)); + if app.state::().remote_zoom_levels.lock().unwrap() + .get("remote-fixture").is_some_and(|zoom| *zoom > 2.0) + { + break; + } + assert!(Instant::now() < deadline, "native Ctrl-wheel zoom callback timed out"); + } + assert!(window.is_maximized().unwrap()); + println!("PASS: remote native COM zoom callback while maximized"); + webview.set_zoom(2.0).unwrap(); + window_constraints::apply(&window, 2.0); + } + assert!( + window.is_maximized().unwrap(), + "zoom unmaximized {}", + window.label() + ); + assert_eq!( + normal_rect(&window), + normal, + "zoom changed normal placement" + ); + window.unmaximize().unwrap(); + wait(|| !window.is_maximized().unwrap()); + wait(|| { + window_constraints::snapshot(&window) + .is_some_and(|state| state.0 == 2.0) + }); + let state = window_constraints::snapshot(&window).unwrap(); + let scale = window.scale_factor().unwrap(); + let size = window.inner_size().unwrap().to_logical::(scale); + assert!(size.width + 1.0 >= state.1 && size.height + 1.0 >= state.2); + + window.set_fullscreen(true).unwrap(); + wait(|| window.is_fullscreen().unwrap()); + webview.set_zoom(0.5).unwrap(); + window_constraints::apply(&window, 0.5); + thread::sleep(Duration::from_millis(800)); + assert!(window.is_fullscreen().unwrap(), "zoom exited fullscreen"); + window.set_fullscreen(false).unwrap(); + wait(|| !window.is_fullscreen().unwrap()); + wait(|| window_constraints::snapshot(&window) == Some((0.5, 195.0, 300.0))); + window.set_size(LogicalSize::new(250.0, 350.0)).unwrap(); + wait(|| { + window + .inner_size() + .unwrap() + .to_logical::(window.scale_factor().unwrap()) + .width + < 390.0 + }); + println!( + "PASS: {} maximize/fullscreen/latest-zoom/normal restore", + window.label() + ); + + // The environment refresh must work without a zoom event. + window_constraints::apply(&window, 5.0); + for monitor in window.available_monitors().unwrap() { + let work = monitor.work_area(); + window + .set_position(PhysicalPosition::new( + work.position.x + 20, + work.position.y + 20, + )) + .unwrap(); + thread::sleep(Duration::from_millis(800)); + let state = window_constraints::snapshot(&window).unwrap(); + let scale = window.scale_factor().unwrap(); + let size = window.inner_size().unwrap(); + let outer = window.outer_size().unwrap(); + let current_monitor = window.current_monitor().unwrap().unwrap(); + let area = current_monitor.work_area(); + let max_width = + f64::from(area.size.width - outer.width.saturating_sub(size.width)) + / scale; + let max_height = f64::from( + area.size.height - outer.height.saturating_sub(size.height), + ) / scale; + assert!( + state.1 <= max_width && state.2 <= max_height, + "stale monitor cap" + ); + println!( + "PASS: {} monitor {:?} scale {} cap {}x{}", + window.label(), + monitor.name(), + scale, + state.1, + state.2 + ); + } + } + if !baseline { + let window = startup.as_ref().window(); + assert!(window.is_maximized().unwrap()); + assert_eq!(window_constraints::snapshot(&window), None, "startup setter ran while maximized"); + window.unmaximize().unwrap(); + wait(|| window_constraints::snapshot(&window).is_some()); + let minimum = window_constraints::snapshot(&window).unwrap(); + assert_eq!(minimum.0, 5.0); + println!("PASS: initial maximized restore defers saved zoom until normal"); + // Allow all initial writes to settle, then count real + // native geometry messages across fallback refreshes. + thread::sleep(Duration::from_millis(800)); + let before = POSITION_CHANGES.load(std::sync::atomic::Ordering::SeqCst); + thread::sleep(Duration::from_millis(1200)); + assert_eq!(POSITION_CHANGES.load(std::sync::atomic::Ordering::SeqCst), before, + "unchanged constraints caused native resize recursion"); + println!("PASS: unchanged fallback checks produce no native geometry writes"); + } + })); + // Keep isolated WebView2 data alive until all test windows close. + let _profile = profile; + if result.is_err() { + eprintln!("FAIL: isolated Windows window constraints"); + std::process::exit(2); + } else { + println!("PASS: isolated Windows window constraints"); + app.exit(0); + } + }); + Ok(()) + }) + .run(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("run isolated Tauri fixture"); +} 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 04e93648a..d18bcc60c 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json +++ b/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-browser-target-action":{"identifier":"allow-browser-target-action","description":"Enables the browser_target_action command without any pre-configured scope.","commands":{"allow":["browser_target_action"],"deny":[]}},"allow-browser-target-claim-open":{"identifier":"allow-browser-target-claim-open","description":"Enables the browser_target_claim_open command without any pre-configured scope.","commands":{"allow":["browser_target_claim_open"],"deny":[]}},"allow-browser-target-register":{"identifier":"allow-browser-target-register","description":"Enables the browser_target_register command without any pre-configured scope.","commands":{"allow":["browser_target_register"],"deny":[]}},"allow-browser-target-release-open":{"identifier":"allow-browser-target-release-open","description":"Enables the browser_target_release_open command without any pre-configured scope.","commands":{"allow":["browser_target_release_open"],"deny":[]}},"allow-browser-target-unregister":{"identifier":"allow-browser-target-unregister","description":"Enables the browser_target_unregister command without any pre-configured scope.","commands":{"allow":["browser_target_unregister"],"deny":[]}},"allow-browser-target-update":{"identifier":"allow-browser-target-update","description":"Enables the browser_target_update command without any pre-configured scope.","commands":{"allow":["browser_target_update"],"deny":[]}},"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-preferences-window":{"identifier":"allow-open-preferences-window","description":"Enables the open_preferences_window command without any pre-configured scope.","commands":{"allow":["open_preferences_window"],"deny":[]}},"allow-open-remote-window":{"identifier":"allow-open-remote-window","description":"Enables the open_remote_window command without any pre-configured scope.","commands":{"allow":["open_remote_window"],"deny":[]}},"allow-open-workspace-target":{"identifier":"allow-open-workspace-target","description":"Enables the open_workspace_target command without any pre-configured scope.","commands":{"allow":["open_workspace_target"],"deny":[]}},"allow-popup-titlebar-menu":{"identifier":"allow-popup-titlebar-menu","description":"Enables the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":["popup_titlebar_menu"],"deny":[]}},"allow-preferences-accept-request":{"identifier":"allow-preferences-accept-request","description":"Enables the preferences_accept_request command without any pre-configured scope.","commands":{"allow":["preferences_accept_request"],"deny":[]}},"allow-preferences-get-request":{"identifier":"allow-preferences-get-request","description":"Enables the preferences_get_request command without any pre-configured scope.","commands":{"allow":["preferences_get_request"],"deny":[]}},"allow-preferences-resolve-transition":{"identifier":"allow-preferences-resolve-transition","description":"Enables the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":["preferences_resolve_transition"],"deny":[]}},"allow-preferences-window-ready":{"identifier":"allow-preferences-window-ready","description":"Enables the preferences_window_ready command without any pre-configured scope.","commands":{"allow":["preferences_window_ready"],"deny":[]}},"allow-set-workspace-menu-enabled":{"identifier":"allow-set-workspace-menu-enabled","description":"Enables the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":["set_workspace_menu_enabled"],"deny":[]}},"allow-wake-lock-start":{"identifier":"allow-wake-lock-start","description":"Enables the wake_lock_start command without any pre-configured scope.","commands":{"allow":["wake_lock_start"],"deny":[]}},"allow-wake-lock-stop":{"identifier":"allow-wake-lock-stop","description":"Enables the wake_lock_stop command without any pre-configured scope.","commands":{"allow":["wake_lock_stop"],"deny":[]}},"allow-window-control":{"identifier":"allow-window-control","description":"Enables the window_control command without any pre-configured scope.","commands":{"allow":["window_control"],"deny":[]}},"deny-browser-target-action":{"identifier":"deny-browser-target-action","description":"Denies the browser_target_action command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_action"]}},"deny-browser-target-claim-open":{"identifier":"deny-browser-target-claim-open","description":"Denies the browser_target_claim_open command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_claim_open"]}},"deny-browser-target-register":{"identifier":"deny-browser-target-register","description":"Denies the browser_target_register command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_register"]}},"deny-browser-target-release-open":{"identifier":"deny-browser-target-release-open","description":"Denies the browser_target_release_open command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_release_open"]}},"deny-browser-target-unregister":{"identifier":"deny-browser-target-unregister","description":"Denies the browser_target_unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_unregister"]}},"deny-browser-target-update":{"identifier":"deny-browser-target-update","description":"Denies the browser_target_update command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_update"]}},"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-preferences-window":{"identifier":"deny-open-preferences-window","description":"Denies the open_preferences_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_preferences_window"]}},"deny-open-remote-window":{"identifier":"deny-open-remote-window","description":"Denies the open_remote_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_remote_window"]}},"deny-open-workspace-target":{"identifier":"deny-open-workspace-target","description":"Denies the open_workspace_target command without any pre-configured scope.","commands":{"allow":[],"deny":["open_workspace_target"]}},"deny-popup-titlebar-menu":{"identifier":"deny-popup-titlebar-menu","description":"Denies the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["popup_titlebar_menu"]}},"deny-preferences-accept-request":{"identifier":"deny-preferences-accept-request","description":"Denies the preferences_accept_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_accept_request"]}},"deny-preferences-get-request":{"identifier":"deny-preferences-get-request","description":"Denies the preferences_get_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_get_request"]}},"deny-preferences-resolve-transition":{"identifier":"deny-preferences-resolve-transition","description":"Denies the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_resolve_transition"]}},"deny-preferences-window-ready":{"identifier":"deny-preferences-window-ready","description":"Denies the preferences_window_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_window_ready"]}},"deny-set-workspace-menu-enabled":{"identifier":"deny-set-workspace-menu-enabled","description":"Denies the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_workspace_menu_enabled"]}},"deny-wake-lock-start":{"identifier":"deny-wake-lock-start","description":"Denies the wake_lock_start command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_start"]}},"deny-wake-lock-stop":{"identifier":"deny-wake-lock-stop","description":"Denies the wake_lock_stop command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_stop"]}},"deny-window-control":{"identifier":"deny-window-control","description":"Denies the window_control command without any pre-configured scope.","commands":{"allow":[],"deny":["window_control"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-browser-target-action":{"identifier":"allow-browser-target-action","description":"Enables the browser_target_action command without any pre-configured scope.","commands":{"allow":["browser_target_action"],"deny":[]}},"allow-browser-target-claim-open":{"identifier":"allow-browser-target-claim-open","description":"Enables the browser_target_claim_open command without any pre-configured scope.","commands":{"allow":["browser_target_claim_open"],"deny":[]}},"allow-browser-target-register":{"identifier":"allow-browser-target-register","description":"Enables the browser_target_register command without any pre-configured scope.","commands":{"allow":["browser_target_register"],"deny":[]}},"allow-browser-target-release-open":{"identifier":"allow-browser-target-release-open","description":"Enables the browser_target_release_open command without any pre-configured scope.","commands":{"allow":["browser_target_release_open"],"deny":[]}},"allow-browser-target-unregister":{"identifier":"allow-browser-target-unregister","description":"Enables the browser_target_unregister command without any pre-configured scope.","commands":{"allow":["browser_target_unregister"],"deny":[]}},"allow-browser-target-update":{"identifier":"allow-browser-target-update","description":"Enables the browser_target_update command without any pre-configured scope.","commands":{"allow":["browser_target_update"],"deny":[]}},"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-preferences-window":{"identifier":"allow-open-preferences-window","description":"Enables the open_preferences_window command without any pre-configured scope.","commands":{"allow":["open_preferences_window"],"deny":[]}},"allow-open-remote-window":{"identifier":"allow-open-remote-window","description":"Enables the open_remote_window command without any pre-configured scope.","commands":{"allow":["open_remote_window"],"deny":[]}},"allow-open-workspace-target":{"identifier":"allow-open-workspace-target","description":"Enables the open_workspace_target command without any pre-configured scope.","commands":{"allow":["open_workspace_target"],"deny":[]}},"allow-owned-webview-zoom":{"identifier":"allow-owned-webview-zoom","description":"Enables the owned_webview_zoom command without any pre-configured scope.","commands":{"allow":["owned_webview_zoom"],"deny":[]}},"allow-popup-titlebar-menu":{"identifier":"allow-popup-titlebar-menu","description":"Enables the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":["popup_titlebar_menu"],"deny":[]}},"allow-preferences-accept-request":{"identifier":"allow-preferences-accept-request","description":"Enables the preferences_accept_request command without any pre-configured scope.","commands":{"allow":["preferences_accept_request"],"deny":[]}},"allow-preferences-get-request":{"identifier":"allow-preferences-get-request","description":"Enables the preferences_get_request command without any pre-configured scope.","commands":{"allow":["preferences_get_request"],"deny":[]}},"allow-preferences-resolve-transition":{"identifier":"allow-preferences-resolve-transition","description":"Enables the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":["preferences_resolve_transition"],"deny":[]}},"allow-preferences-window-ready":{"identifier":"allow-preferences-window-ready","description":"Enables the preferences_window_ready command without any pre-configured scope.","commands":{"allow":["preferences_window_ready"],"deny":[]}},"allow-set-workspace-menu-enabled":{"identifier":"allow-set-workspace-menu-enabled","description":"Enables the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":["set_workspace_menu_enabled"],"deny":[]}},"allow-wake-lock-start":{"identifier":"allow-wake-lock-start","description":"Enables the wake_lock_start command without any pre-configured scope.","commands":{"allow":["wake_lock_start"],"deny":[]}},"allow-wake-lock-stop":{"identifier":"allow-wake-lock-stop","description":"Enables the wake_lock_stop command without any pre-configured scope.","commands":{"allow":["wake_lock_stop"],"deny":[]}},"allow-window-control":{"identifier":"allow-window-control","description":"Enables the window_control command without any pre-configured scope.","commands":{"allow":["window_control"],"deny":[]}},"deny-browser-target-action":{"identifier":"deny-browser-target-action","description":"Denies the browser_target_action command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_action"]}},"deny-browser-target-claim-open":{"identifier":"deny-browser-target-claim-open","description":"Denies the browser_target_claim_open command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_claim_open"]}},"deny-browser-target-register":{"identifier":"deny-browser-target-register","description":"Denies the browser_target_register command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_register"]}},"deny-browser-target-release-open":{"identifier":"deny-browser-target-release-open","description":"Denies the browser_target_release_open command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_release_open"]}},"deny-browser-target-unregister":{"identifier":"deny-browser-target-unregister","description":"Denies the browser_target_unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_unregister"]}},"deny-browser-target-update":{"identifier":"deny-browser-target-update","description":"Denies the browser_target_update command without any pre-configured scope.","commands":{"allow":[],"deny":["browser_target_update"]}},"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-preferences-window":{"identifier":"deny-open-preferences-window","description":"Denies the open_preferences_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_preferences_window"]}},"deny-open-remote-window":{"identifier":"deny-open-remote-window","description":"Denies the open_remote_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_remote_window"]}},"deny-open-workspace-target":{"identifier":"deny-open-workspace-target","description":"Denies the open_workspace_target command without any pre-configured scope.","commands":{"allow":[],"deny":["open_workspace_target"]}},"deny-owned-webview-zoom":{"identifier":"deny-owned-webview-zoom","description":"Denies the owned_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["owned_webview_zoom"]}},"deny-popup-titlebar-menu":{"identifier":"deny-popup-titlebar-menu","description":"Denies the popup_titlebar_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["popup_titlebar_menu"]}},"deny-preferences-accept-request":{"identifier":"deny-preferences-accept-request","description":"Denies the preferences_accept_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_accept_request"]}},"deny-preferences-get-request":{"identifier":"deny-preferences-get-request","description":"Denies the preferences_get_request command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_get_request"]}},"deny-preferences-resolve-transition":{"identifier":"deny-preferences-resolve-transition","description":"Denies the preferences_resolve_transition command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_resolve_transition"]}},"deny-preferences-window-ready":{"identifier":"deny-preferences-window-ready","description":"Denies the preferences_window_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["preferences_window_ready"]}},"deny-set-workspace-menu-enabled":{"identifier":"deny-set-workspace-menu-enabled","description":"Denies the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_workspace_menu_enabled"]}},"deny-wake-lock-start":{"identifier":"deny-wake-lock-start","description":"Denies the wake_lock_start command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_start"]}},"deny-wake-lock-stop":{"identifier":"deny-wake-lock-stop","description":"Denies the wake_lock_stop command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_stop"]}},"deny-window-control":{"identifier":"deny-window-control","description":"Denies the window_control command without any pre-configured scope.","commands":{"allow":[],"deny":["window_control"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json index d0071add8..199019ee2 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json +++ b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant local windows access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:1420","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"webviews":["local-*"],"permissions":["core:default","core:menu:default","allow-window-control","allow-popup-titlebar-menu","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom","allow-cli-get-status","allow-cli-restart","allow-wake-lock-start","allow-wake-lock-stop","allow-needs-local-certificate-install","allow-open-preferences-window","allow-open-remote-window","allow-client-state-claim-access","allow-client-state-load","allow-client-state-save","allow-client-state-commit-partitions","allow-client-state-load-partition","allow-client-state-set-restore-enabled","allow-client-state-clear","allow-client-state-renderer-flushed","allow-client-state-navigation-flushed","allow-desktop-launch-ready","allow-desktop-launch-next-folder","allow-desktop-launch-acknowledge-folder","allow-install-stable-update","allow-open-workspace-target","allow-set-workspace-menu-enabled","allow-browser-target-register","allow-browser-target-update","allow-browser-target-action","allow-browser-target-unregister","allow-browser-target-claim-open","allow-browser-target-release-open"]},"preferences-window":{"identifier":"preferences-window","description":"Grant the singleton Preferences window only settings and HTML window-chrome capabilities.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","https://127.0.0.1:*","https://localhost:*"]},"local":true,"windows":["preferences"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-preferences-window-ready","allow-preferences-get-request","allow-preferences-accept-request","allow-preferences-resolve-transition","allow-window-control","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","allow-cli-get-status","allow-cli-restart","allow-needs-local-certificate-install","allow-open-remote-window"]},"remote-window-notifications":{"identifier":"remote-window-notifications","description":"Grant remote CodeNomad windows access only to native OS notifications.","remote":{"urls":["http://*:*","https://*:*"]},"local":false,"windows":["remote-*"],"permissions":["notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]}} \ No newline at end of file +{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant local windows access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:1420","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"webviews":["local-*"],"permissions":["core:default","core:menu:default","allow-window-control","allow-popup-titlebar-menu","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","allow-cli-get-status","allow-cli-restart","allow-wake-lock-start","allow-wake-lock-stop","allow-needs-local-certificate-install","allow-open-preferences-window","allow-open-remote-window","allow-client-state-claim-access","allow-client-state-load","allow-client-state-save","allow-client-state-commit-partitions","allow-client-state-load-partition","allow-client-state-set-restore-enabled","allow-client-state-clear","allow-client-state-renderer-flushed","allow-client-state-navigation-flushed","allow-desktop-launch-ready","allow-desktop-launch-next-folder","allow-desktop-launch-acknowledge-folder","allow-install-stable-update","allow-open-workspace-target","allow-set-workspace-menu-enabled","allow-browser-target-register","allow-browser-target-update","allow-browser-target-action","allow-browser-target-unregister","allow-browser-target-claim-open","allow-browser-target-release-open"]},"owned-webview-zoom":{"identifier":"owned-webview-zoom","description":"Allow primary application webviews to request bounded relative zoom of themselves only; the host validates registered ownership and renderer origin.","remote":{"urls":["http://*:*","https://*:*"]},"local":true,"webviews":["local-*","remote-*","preferences"],"permissions":["allow-owned-webview-zoom"]},"preferences-window":{"identifier":"preferences-window","description":"Grant the singleton Preferences window only settings and HTML window-chrome capabilities.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","https://127.0.0.1:*","https://localhost:*"]},"local":true,"webviews":["preferences"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-preferences-window-ready","allow-preferences-get-request","allow-preferences-accept-request","allow-preferences-resolve-transition","allow-window-control","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","allow-cli-get-status","allow-cli-restart","allow-needs-local-certificate-install","allow-open-remote-window"]},"remote-window-notifications":{"identifier":"remote-window-notifications","description":"Grant remote CodeNomad windows access only to native OS notifications.","remote":{"urls":["http://*:*","https://*:*"]},"local":false,"webviews":["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 1dfc134eb..52f3c1180 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json @@ -494,6 +494,12 @@ "const": "allow-open-workspace-target", "markdownDescription": "Enables the open_workspace_target command without any pre-configured scope." }, + { + "description": "Enables the owned_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "allow-owned-webview-zoom", + "markdownDescription": "Enables the owned_webview_zoom command without any pre-configured scope." + }, { "description": "Enables the popup_titlebar_menu command without any pre-configured scope.", "type": "string", @@ -698,6 +704,12 @@ "const": "deny-open-workspace-target", "markdownDescription": "Denies the open_workspace_target command without any pre-configured scope." }, + { + "description": "Denies the owned_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "deny-owned-webview-zoom", + "markdownDescription": "Denies the owned_webview_zoom command without any pre-configured scope." + }, { "description": "Denies the popup_titlebar_menu command without any pre-configured scope.", "type": "string", diff --git a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json index 1dfc134eb..52f3c1180 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json @@ -494,6 +494,12 @@ "const": "allow-open-workspace-target", "markdownDescription": "Enables the open_workspace_target command without any pre-configured scope." }, + { + "description": "Enables the owned_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "allow-owned-webview-zoom", + "markdownDescription": "Enables the owned_webview_zoom command without any pre-configured scope." + }, { "description": "Enables the popup_titlebar_menu command without any pre-configured scope.", "type": "string", @@ -698,6 +704,12 @@ "const": "deny-open-workspace-target", "markdownDescription": "Denies the open_workspace_target command without any pre-configured scope." }, + { + "description": "Denies the owned_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "deny-owned-webview-zoom", + "markdownDescription": "Denies the owned_webview_zoom command without any pre-configured scope." + }, { "description": "Denies the popup_titlebar_menu command without any pre-configured scope.", "type": "string", diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/owned_webview_zoom.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/owned_webview_zoom.toml new file mode 100644 index 000000000..b8fa678c5 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/owned_webview_zoom.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-owned-webview-zoom" +description = "Enables the owned_webview_zoom command without any pre-configured scope." +commands.allow = ["owned_webview_zoom"] + +[[permission]] +identifier = "deny-owned-webview-zoom" +description = "Denies the owned_webview_zoom command without any pre-configured scope." +commands.deny = ["owned_webview_zoom"] diff --git a/packages/tauri-app/src-tauri/src/browser_controller.rs b/packages/tauri-app/src-tauri/src/browser_controller.rs index 460c41831..63b51ddc5 100644 --- a/packages/tauri-app/src-tauri/src/browser_controller.rs +++ b/packages/tauri-app/src-tauri/src/browser_controller.rs @@ -8,6 +8,10 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::webview::PageLoadEvent; use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, Window}; +#[cfg(windows)] +#[path = "browser_emulation.rs"] +mod emulation; + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct BrowserTargetRegistration { @@ -39,6 +43,7 @@ pub(crate) struct BrowserTargetAction { registration_id: String, action: String, url: Option, + preset: Option, } #[derive(Clone)] @@ -69,6 +74,7 @@ struct OpenClaim { #[derive(Default)] struct Inner { + emulation_profiles: HashMap, registrations: HashMap, renderer_versions: HashMap, refs: HashMap>, @@ -80,6 +86,7 @@ struct Inner { #[derive(Clone)] pub(crate) struct BrowserController { + emulation_lock: Arc>, inner: Arc<(Mutex, Condvar)>, profile: Arc, registration_sequence: Arc, @@ -88,6 +95,7 @@ pub(crate) struct BrowserController { impl BrowserController { pub(crate) fn new(profile: std::path::PathBuf) -> Self { Self { + emulation_lock: Arc::new(Mutex::new(())), inner: Arc::new((Mutex::new(Inner::default()), Condvar::new())), profile: Arc::new(profile), registration_sequence: Arc::new(AtomicU64::new(1)), @@ -296,6 +304,7 @@ impl BrowserController { .get_webview(®istration.webview_label) .ok_or_else(|| "Browser preview is no longer available".to_string())?; match input.action.as_str() { + "emulate" => self.emulate(app, window_label, &input), "back" => webview .eval("history.back()") .map_err(|error| error.to_string()), @@ -334,6 +343,7 @@ impl BrowserController { return Err("Browser target belongs to another window".to_string()); } let registration = inner.registrations.remove(registration_id).unwrap(); + inner.emulation_profiles.remove(registration_id); inner.refs.remove(registration_id); inner.navigation_versions.remove(registration_id); inner.page_load_expectations.remove(registration_id); diff --git a/packages/tauri-app/src-tauri/src/browser_emulation.rs b/packages/tauri-app/src-tauri/src/browser_emulation.rs new file mode 100644 index 000000000..7f144676e --- /dev/null +++ b/packages/tauri-app/src-tauri/src/browser_emulation.rs @@ -0,0 +1,114 @@ +use super::*; + +impl BrowserController { + pub(super) fn emulate( + &self, + app: &AppHandle, + owner: &str, + input: &BrowserTargetAction, + ) -> Result<(), String> { + let preset = input.preset.as_deref().unwrap_or(""); + profile(preset)?; + // Never hold the controller state mutex while waiting for WebView2. + let _admission = self + .emulation_lock + .try_lock() + .map_err(|_| "Browser emulation is already changing".to_string())?; + let registration = self.owned_registration(owner, &input.registration_id)?; + let webview = app + .get_webview(®istration.webview_label) + .ok_or_else(|| "Browser preview is no longer available".to_string())?; + let previous = self + .inner + .0 + .lock() + .map_err(|e| e.to_string())? + .emulation_profiles + .get(&input.registration_id) + .cloned() + .unwrap_or_else(|| "none".into()); + if previous == preset { + return Ok(()); + } + if let Err(error) = apply(&webview, preset) { + apply(&webview, &previous)?; + return Err(error); + } + let current = self.owned_registration(owner, &input.registration_id)?; + if current.generation != registration.generation { + return Err("Browser target changed before emulation".into()); + } + self.inner + .0 + .lock() + .map_err(|e| e.to_string())? + .emulation_profiles + .insert(input.registration_id.clone(), preset.into()); + webview.reload().map_err(|e| e.to_string()) + } +} + +fn apply(webview: &tauri::Webview, preset: &str) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(20); + let send = |method, params| cdp(webview, method, params, deadline).map(|_| ()); + if preset == "none" { + send("Emulation.clearDeviceMetricsOverride", json!({}))?; + send( + "Emulation.setTouchEmulationEnabled", + json!({ "enabled": false }), + )?; + send( + "Emulation.setEmitTouchEventsForMouse", + json!({ "enabled": false }), + )?; + return send("Emulation.setUserAgentOverride", json!({ "userAgent": "" })); + } + let profiles: Value = serde_json::from_str(include_str!( + "../../../ui/src/lib/native/browser-emulation.json" + )) + .map_err(|e| e.to_string())?; + let (size, landscape) = profile(preset)?.unwrap(); + let dimensions = &profiles[size]; + let width = &dimensions[if landscape { "height" } else { "width" }]; + let height = &dimensions[if landscape { "width" } else { "height" }]; + let version = cdp(webview, "Browser.getVersion", json!({}), deadline)?; + let full_version = version["product"] + .as_str() + .and_then(|v| v.rsplit('/').next()) + .ok_or_else(|| "Chromium version is unavailable".to_string())?; + send( + "Emulation.setDeviceMetricsOverride", + json!({ + "width": width, "height": height, "deviceScaleFactor": profiles["deviceScaleFactor"], + "mobile": true, "screenWidth": width, "screenHeight": height, + "screenOrientation": { "type": if landscape { "landscapePrimary" } else { "portraitPrimary" }, "angle": if landscape { 90 } else { 0 } } + }), + )?; + send( + "Emulation.setTouchEmulationEnabled", + json!({ "enabled": true, "maxTouchPoints": 5 }), + )?; + send( + "Emulation.setEmitTouchEventsForMouse", + json!({ "enabled": true, "configuration": "mobile" }), + )?; + send( + "Emulation.setUserAgentOverride", + json!({ + "userAgent": profiles["userAgent"].as_str().unwrap().replace("{version}", full_version), + "platform": "Linux armv8l", + "userAgentMetadata": { "brands": [{ "brand": "Chromium", "version": full_version.split('.').next().unwrap() }], + "fullVersion": full_version, "platform": "Android", "platformVersion": "11.0.0", + "architecture": "", "model": "", "mobile": true } + }), + ) +} + +fn profile(preset: &str) -> Result, String> { + if preset == "none" { return Ok(None); } + let size = preset.strip_suffix("Landscape").unwrap_or(preset); + if size != "mobile" { + return Err("Invalid browser emulation profile".into()); + } + Ok(Some((size, size != preset))) +} diff --git a/packages/tauri-app/src-tauri/src/client_state.rs b/packages/tauri-app/src-tauri/src/client_state.rs index 272c09fac..136528624 100644 --- a/packages/tauri-app/src-tauri/src/client_state.rs +++ b/packages/tauri-app/src-tauri/src/client_state.rs @@ -27,7 +27,7 @@ pub(crate) use navigation::{ }; pub use window::{ capture_and_flush_all_windows, capture_and_flush_window, local_window_zoom, - set_local_window_zoom, setup_local_window, DEFAULT_ZOOM_LEVEL, + set_local_window_zoom, setup_local_window, DEFAULT_ZOOM_LEVEL, MIN_WINDOW_WIDTH, }; use envelope::PersistedClientState; 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 0363305b9..7497dc18c 100644 --- a/packages/tauri-app/src-tauri/src/client_state/tests.rs +++ b/packages/tauri-app/src-tauri/src/client_state/tests.rs @@ -556,6 +556,12 @@ fn electron_and_tauri_share_the_complete_envelope_across_handoffs() { } #[test] fn normalizes_window_bounds_against_displays() { + for width in [320, 390, 430] { + assert_eq!( + clamp_window_bounds(&bounds(20, 30, width, 844), &[display(0, 0, 1920, 1080)]), + Some(bounds(20, 30, width.max(390), 844)) + ); + } let cases = [ ( bounds(4000, 2000, 1400, 900), @@ -581,7 +587,7 @@ fn mixed_dpi_restore_selects_displays_in_physical_coordinates() { &bounds(1000, 500, 700, 600), &[display(0, 0, 1920, 1080), high_dpi], ), - Some(bounds(1000, 120, 800, 600)) + Some(bounds(1000, 120, 700, 600)) ); } #[test] 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 a64fdb7b7..cc73e44e1 100644 --- a/packages/tauri-app/src-tauri/src/client_state/window.rs +++ b/packages/tauri-app/src-tauri/src/client_state/window.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, WindowEvent}; -const MIN_WINDOW_WIDTH: i32 = 800; +pub const MIN_WINDOW_WIDTH: i32 = 390; const MIN_WINDOW_HEIGHT: i32 = 600; const MIN_ZOOM_LEVEL: f64 = 0.25; pub(super) const MAX_ZOOM_LEVEL: f64 = 5.0; @@ -272,6 +272,10 @@ fn register_native_zoom_handler( zoom_levels.insert(window_id.clone(), normalized); drop(zoom_levels); + if let Some(window) = callback_app.get_window(&window_label) { + crate::window_constraints::apply(&window, normalized); + } + if capture_window_in_memory(&callback_app, &window_label, &window_id, persisted) { schedule_flush(&callback_app); } @@ -306,6 +310,7 @@ pub fn setup_local_window( #[cfg(windows)] register_native_zoom_handler(window, app, window_id.to_string(), persisted); if !client_state.is_primary() || !persisted { + crate::window_constraints::register(&window.as_ref().window(), initial_zoom); let _ = window.show(); return Ok(()); } @@ -316,7 +321,10 @@ pub fn setup_local_window( state.preferences_window.clone() } else { let record = state.record(window_id)?; - record.restore_enabled.then(|| record.window.clone()).flatten() + record + .restore_enabled + .then(|| record.window.clone()) + .flatten() } }; if let Some(mut saved_window) = saved_window { @@ -338,15 +346,26 @@ pub fn setup_local_window( let minimum = if window_id == crate::preferences_window::LABEL { (760, 560) } else { - (MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) + crate::window_constraints::zoomed_minimum(saved_window.zoom_factor) }; - if let Some(bounds) = clamp_window_bounds_for_restore(&saved_window.bounds, &displays, minimum) { + if let Some(bounds) = + clamp_window_bounds_for_restore(&saved_window.bounds, &displays, minimum) + { + // The builder's unzoomed minimum must not block a smaller saved + // zoom. Place on the restored monitor before sizing/recomputing its + // work-area cap; the deferred constraint worker runs after setup. + if window_id != crate::preferences_window::LABEL + && matches!(window.is_maximized(), Ok(false)) + && matches!(window.is_fullscreen(), Ok(false)) + { + let _ = window.set_min_size(None::>); + } + let _ = + window.set_position(PhysicalPosition::new(bounds.physical.x, bounds.physical.y)); 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() { @@ -385,6 +404,13 @@ pub fn setup_local_window( } } + // Start constraint tracking only after restore placement, zoom and window + // state have settled. Early COM callbacks update the zoom map but cannot + // resize the default monitor or replace the seeded normal geometry. + crate::window_constraints::register( + &window.as_ref().window(), + local_window_zoom(app, window.label()), + ); if capture_window_in_memory(app, window.label(), window_id, persisted) { schedule_flush(app); } @@ -416,6 +442,7 @@ pub fn set_local_window_zoom(app: &AppHandle, window_label: &str, next_zoom: f64 if webview.set_zoom(normalized).is_err() { return; } + crate::window_constraints::apply(&webview.window(), normalized); let Some(client_state) = app.try_state::() else { return; }; diff --git a/packages/tauri-app/src-tauri/src/local_windows.rs b/packages/tauri-app/src-tauri/src/local_windows.rs index c1d605c80..55f37aea4 100644 --- a/packages/tauri-app/src-tauri/src/local_windows.rs +++ b/packages/tauri-app/src-tauri/src/local_windows.rs @@ -362,15 +362,14 @@ pub(crate) fn create_local_window( } else { builder }; - let result = builder + let result = crate::window_zoom::configure(builder) .title("CodeNomad") .inner_size(1400.0, 900.0) - .min_inner_size(800.0, 600.0) + .min_inner_size(client_state::MIN_WINDOW_WIDTH as f64, 600.0) .resizable(true) .fullscreen(false) .decorations(false) .background_color(tauri::window::Color(26, 26, 26, 255)) - .zoom_hotkeys_enabled(true) .visible(false) .build(); let window = match result { diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 4e98295ee..e5be289f5 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -17,6 +17,8 @@ mod native_service_start; mod preferences_window; mod shutdown; mod view_menu; +mod window_constraints; +mod window_zoom; mod windows_update; mod workspace_open; @@ -495,16 +497,17 @@ fn browser_target_update( } #[tauri::command] -fn browser_target_action( +async fn browser_target_action( webview: tauri::Webview, app: AppHandle, state: tauri::State<'_, AppState>, payload: browser_controller::BrowserTargetAction, ) -> Result<(), String> { require_local_app_webview(&webview, &state)?; - state - .browser_controller - .action(&app, webview.label(), payload) + let controller = state.browser_controller.clone(); + let owner = webview.label().to_string(); + tauri::async_runtime::spawn_blocking(move || controller.action(&app, &owner, payload)) + .await.map_err(|error| error.to_string())? } #[tauri::command] @@ -990,7 +993,8 @@ fn open_remote_window_locked( .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) .title(title) .inner_size(1400.0, 900.0) - .min_inner_size(800.0, 600.0); + .min_inner_size(client_state::MIN_WINDOW_WIDTH as f64, 600.0); + let builder = window_zoom::configure(builder); #[cfg(target_os = "macos")] let builder = builder.data_store_identifier(profile_identifier(&profile_key)); let window = match builder.build() { @@ -1008,6 +1012,9 @@ fn open_remote_window_locked( } }; + window_constraints::register(&window.as_ref().window(), 1.0); + #[cfg(windows)] + window_constraints::register_remote_zoom(&window, &app); #[cfg(windows)] if let Err(error) = shutdown::schedule_windows_session_end_handler(&window) { cleanup_failed_remote_window( @@ -1417,6 +1424,7 @@ fn set_target_zoom(app: &AppHandle, webview: &tauri::Webview, zoom: f64) { } let zoom = zoom.clamp(0.25, 5.0); if webview.set_zoom(zoom).is_ok() { + window_constraints::apply(&webview.window(), zoom); if let Ok(mut levels) = app.state::().remote_zoom_levels.lock() { levels.insert(webview.label().to_string(), zoom); } @@ -1635,6 +1643,7 @@ fn main() { .plugin(tauri_plugin_notification::init()) .plugin(navigation_guard) .manage(local_windows::LocalWindows::default()) + .manage(window_constraints::WindowConstraints::default()) .manage(preferences_window::PreferencesWindow::default()) .manage(AppState { manager: CliProcessManager::new(), @@ -1741,6 +1750,7 @@ fn main() { preferences_window::preferences_accept_request, preferences_window::preferences_resolve_transition, window_control, + window_zoom::owned_webview_zoom, popup_titlebar_menu, open_remote_window, client_state::client_state_claim_access, @@ -2400,7 +2410,8 @@ mod menu_tests { capability["remote"]["urls"], json!(["http://*:*", "https://*:*"]) ); - assert_eq!(capability["windows"], json!(["remote-*"])); + assert_eq!(capability["webviews"], json!(["remote-*"])); + assert!(capability["windows"].is_null()); assert_eq!( capability["permissions"], json!([ diff --git a/packages/tauri-app/src-tauri/src/preferences_window.rs b/packages/tauri-app/src-tauri/src/preferences_window.rs index 295ae13ee..f45cfc2f6 100644 --- a/packages/tauri-app/src-tauri/src/preferences_window.rs +++ b/packages/tauri-app/src-tauri/src/preferences_window.rs @@ -302,7 +302,7 @@ fn open_preferences( } else { builder }; - let preferences_window = builder + let preferences_window = crate::window_zoom::configure(builder) .title("Preferences") .inner_size(1100.0, 760.0) .min_inner_size(760.0, 560.0) @@ -310,7 +310,6 @@ fn open_preferences( .fullscreen(false) .decorations(false) .background_color(tauri::window::Color(26, 26, 26, 255)) - .zoom_hotkeys_enabled(true) .visible(false) .build() .map_err(|error| error.to_string())?; @@ -617,7 +616,8 @@ mod tests { fn preferences_capability_is_exact_and_excludes_workspace_state_authority() { let capability: serde_json::Value = serde_json::from_str(include_str!("../capabilities/preferences-window.json")).unwrap(); - assert_eq!(capability["windows"], json!([LABEL])); + assert_eq!(capability["webviews"], json!([LABEL])); + assert!(capability.get("windows").is_none()); let permissions = capability["permissions"].as_array().unwrap(); for excluded in [ "client-state", diff --git a/packages/tauri-app/src-tauri/src/window_constraints.rs b/packages/tauri-app/src-tauri/src/window_constraints.rs new file mode 100644 index 000000000..5110e2bba --- /dev/null +++ b/packages/tauri-app/src-tauri/src/window_constraints.rs @@ -0,0 +1,363 @@ +use std::collections::HashMap; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; +use tauri::{LogicalSize, Manager, PhysicalSize, Window, WindowEvent}; + +// The pinned runtime has resize/move/DPI events, but no work-area or window-state +// event. A bounded fallback also detects taskbar/display changes and transitions +// that do not change the client size. No native setters run in event callbacks. +const ENVIRONMENT_CHECK: Duration = Duration::from_millis(500); +const COALESCE: Duration = Duration::from_millis(16); + +#[derive(Default)] +pub(crate) struct WindowConstraints(Mutex>>); + +struct Registration { + updates: Mutex, + wake: Condvar, +} + +struct Updates { + zoom: f64, + applied: Option, + dirty: bool, + queued: bool, + closed: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Minimum { + width: f64, + height: f64, + scale: f64, +} + +fn normalize_zoom(zoom: f64) -> f64 { + if zoom.is_finite() && zoom > 0.0 { + zoom.clamp(0.25, 5.0) + } else { + 1.0 + } +} + +pub(crate) fn zoomed_minimum(zoom: f64) -> (i32, i32) { + let zoom = normalize_zoom(zoom); + ( + (f64::from(crate::client_state::MIN_WINDOW_WIDTH) * zoom).ceil() as i32, + (600.0 * zoom).ceil() as i32, + ) +} + +fn minimum(zoom: f64, scale: f64, area: Option<(f64, f64)>, chrome: (f64, f64)) -> Minimum { + let (width, height) = zoomed_minimum(zoom); + let mut result = Minimum { + width: f64::from(width), + height: f64::from(height), + scale, + }; + if let Some((width, height)) = area { + result.width = result.width.min((width - chrome.0).floor().max(1.0)); + result.height = result.height.min((height - chrome.1).floor().max(1.0)); + } + result +} + +fn growth(size: PhysicalSize, minimum: Minimum) -> Option> { + let required = + LogicalSize::new(minimum.width, minimum.height).to_physical::(minimum.scale); + // Compare in native pixels, just as Tao does. A rounded 851px at 125% DPI + // is 680.8 logical pixels; repeatedly requesting 681 would never change it. + (size.width < required.width || size.height < required.height).then(|| { + PhysicalSize::new( + size.width.max(required.width), + size.height.max(required.height), + ) + }) +} + +pub(crate) fn register(window: &Window, zoom: f64) { + // Only primary local/remote windows are registered. Preferences and preview + // children retain their own sizing contracts. + if window.label() == crate::preferences_window::LABEL { + return; + } + let registration = Arc::new(Registration { + updates: Mutex::new(Updates { + zoom: normalize_zoom(zoom), + applied: None, + dirty: true, + queued: false, + closed: false, + }), + wake: Condvar::new(), + }); + { + let registry = window.state::(); + let mut entries = registry.0.lock().unwrap_or_else(|e| e.into_inner()); + if entries.contains_key(window.label()) { + return; + } + entries.insert(window.label().to_string(), registration.clone()); + } + let events = registration.clone(); + let app = window.app_handle().clone(); + let label = window.label().to_string(); + window.on_window_event(move |event| { + match event { + WindowEvent::Destroyed => { + events + .updates + .lock() + .unwrap_or_else(|e| e.into_inner()) + .closed = true; + // Fence queued tasks and label reuse without retaining a native + // window in the event listener or touching native APIs under locks. + let registry = app.state::(); + let mut entries = registry.0.lock().unwrap_or_else(|e| e.into_inner()); + if entries + .get(&label) + .is_some_and(|entry| Arc::ptr_eq(entry, &events)) + { + entries.remove(&label); + } + } + WindowEvent::Resized(_) + | WindowEvent::Moved(_) + | WindowEvent::ScaleFactorChanged { .. } + | WindowEvent::Focused(_) => { + events + .updates + .lock() + .unwrap_or_else(|e| e.into_inner()) + .dirty = true; + } + _ => return, + } + events.wake.notify_one(); + }); + let window = window.clone(); + std::thread::spawn(move || { + loop { + let mut updates = registration + .updates + .lock() + .unwrap_or_else(|e| e.into_inner()); + if !updates.dirty || updates.queued { + let (next, timeout) = registration + .wake + .wait_timeout(updates, ENVIRONMENT_CHECK) + .unwrap_or_else(|e| e.into_inner()); + updates = next; + if timeout.timed_out() { + updates.dirty = true; + } + } + if updates.closed { + return; + } + if !updates.dirty || updates.queued { + continue; + } + drop(updates); + // Always post from this worker: run_on_main_thread executes inline + // on the UI thread in Wry, so calling it from WebView2/native callbacks + // does not defer anything and can pump/reenter their locks. + std::thread::sleep(COALESCE); + let mut updates = registration + .updates + .lock() + .unwrap_or_else(|e| e.into_inner()); + if updates.closed { + return; + } + updates.dirty = false; + updates.queued = true; + drop(updates); + let target = window.clone(); + let pending = registration.clone(); + if window + .run_on_main_thread(move || { + refresh(&target, &pending); + pending + .updates + .lock() + .unwrap_or_else(|e| e.into_inner()) + .queued = false; + pending.wake.notify_one(); + }) + .is_err() + { + return; + } + } + }); +} + +// This is the authoritative constraint zoom. Native WebView2 changes and menu +// changes publish here synchronously; queued work reads the latest value rather +// than capturing a stale zoom at scheduling time. +pub(crate) fn apply(window: &Window, zoom: f64) { + let entry = window + .state::() + .0 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(window.label()) + .cloned(); + if let Some(entry) = entry { + let mut updates = entry.updates.lock().unwrap_or_else(|e| e.into_inner()); + updates.zoom = normalize_zoom(zoom); + updates.dirty = true; + drop(updates); + entry.wake.notify_one(); + } +} + +fn refresh(window: &Window, entry: &Registration) { + let (zoom, applied) = { + let updates = entry.updates.lock().unwrap_or_else(|e| e.into_inner()); + if updates.closed { + return; + } + (updates.zoom, updates.applied) + }; + // Tao's Windows set_min_inner_size calls set_inner_size, which unmaximizes. + // Do not even set the minimum while non-normal (or state is unreadable). + // The latest desired zoom remains registered and is applied after restore. + if !matches!(window.is_maximized(), Ok(false)) + || !matches!(window.is_fullscreen(), Ok(false)) + || !matches!(window.is_minimized(), Ok(false)) + { + return; + } + let Ok(scale) = window.scale_factor() else { + return; + }; + let Ok(inner) = window.inner_size() else { + return; + }; + let chrome = window + .outer_size() + .ok() + .map(|outer| { + ( + f64::from(outer.width.saturating_sub(inner.width)) / scale, + f64::from(outer.height.saturating_sub(inner.height)) / scale, + ) + }) + .unwrap_or((0.0, 0.0)); + let area = window.current_monitor().ok().flatten().map(|monitor| { + let size = monitor.work_area().size.to_logical::(scale); + (size.width, size.height) + }); + let next = minimum(zoom, scale, area, chrome); + if applied != Some(next) { + if let Err(error) = window.set_min_size(Some(LogicalSize::new(next.width, next.height))) { + eprintln!("[window] failed to update zoom-dependent minimum: {error}"); + return; + } + entry + .updates + .lock() + .unwrap_or_else(|e| e.into_inner()) + .applied = Some(next); + } + // On Windows the minimum setter already grows the native client area. + // Re-read after a changed minimum to avoid a redundant resize in that case. + let inner = if applied != Some(next) { + window.inner_size().unwrap_or(inner) + } else { + inner + }; + if let Some(size) = growth(inner, next) { + let _ = window.set_size(size); + } +} + +#[cfg(windows)] +pub(crate) fn register_remote_zoom(window: &tauri::WebviewWindow, app: &tauri::AppHandle) { + use webview2_com::ZoomFactorChangedEventHandler; + + let app = app.clone(); + let label = window.label().to_string(); + if let Err(error) = window.with_webview(move |webview| { + let callback_app = app.clone(); + let handler = ZoomFactorChangedEventHandler::create(Box::new(move |sender, _| { + let Some(controller) = sender else { + return Ok(()); + }; + let mut zoom = 1.0; + unsafe { controller.ZoomFactor(&mut zoom)? }; + if !zoom.is_finite() || zoom <= 0.0 { + return Ok(()); + } + if let Ok(mut levels) = callback_app + .state::() + .remote_zoom_levels + .lock() + { + levels.insert(label.clone(), normalize_zoom(zoom)); + } + if let Some(window) = callback_app.get_window(&label) { + apply(&window, zoom); + } + Ok(()) + })); + let mut token = 0; + if let Err(error) = unsafe { + webview + .controller() + .add_ZoomFactorChanged(&handler, &mut token) + } { + eprintln!("[window] failed to register remote zoom handler: {error}"); + } + }) { + eprintln!("[window] failed to access remote zoom controller: {error}"); + } +} + +#[cfg(test)] +mod tests { + use super::{growth, minimum, zoomed_minimum}; + use tauri::PhysicalSize; + + #[test] + fn minimum_tracks_zoom_not_monitor_dpi() { + for (zoom, expected) in [ + (0.5, (195, 300)), + (1.0, (390, 600)), + (1.25, (488, 750)), + (1.5, (585, 900)), + ] { + assert_eq!(zoomed_minimum(zoom), expected); + } + assert_eq!(zoomed_minimum(f64::NAN), (390, 600)); + assert_eq!(zoomed_minimum(0.01), (98, 150)); + assert_eq!(zoomed_minimum(10.0), (1950, 3000)); + } + + #[test] + fn cap_recomputes_for_monitor_dpi_work_area_and_normal_frame() { + let large = minimum(5.0, 1.0, Some((2560.0, 1400.0)), (16.0, 39.0)); + let small = minimum(5.0, 1.0, Some((1280.0, 720.0)), (16.0, 39.0)); + assert_eq!((large.width, large.height), (1950.0, 1361.0)); + assert_eq!((small.width, small.height), (1264.0, 681.0)); + let dpi = minimum(5.0, 2.0, Some((1280.0, 700.0)), (8.0, 20.0)); + assert_eq!((dpi.width, dpi.height), (1272.0, 680.0)); + assert_ne!(large, dpi); + assert_eq!(minimum(1.0, 2.0, None, (8.0, 20.0)).width, 390.0); + assert_eq!( + minimum(5.0, 1.5, Some((800.5, 600.5)), (8.0, 20.0)).height, + 580.0 + ); + } + + #[test] + fn fractional_dpi_does_not_repeat_a_native_no_op_resize() { + let minimum = minimum(5.0, 1.25, Some((1280.0, 720.0)), (16.0, 39.0)); + let target = PhysicalSize::new(1580, 851); + assert_eq!(growth(target, minimum), None); + assert_eq!(growth(PhysicalSize::new(1580, 850), minimum), Some(target)); + assert_eq!(growth(PhysicalSize::new(1800, 1000), minimum), None); + } +} diff --git a/packages/tauri-app/src-tauri/src/window_zoom.js b/packages/tauri-app/src-tauri/src/window_zoom.js new file mode 100644 index 000000000..05d97070d --- /dev/null +++ b/packages/tauri-app/src-tauri/src/window_zoom.js @@ -0,0 +1,53 @@ +(() => { + const platform = __HOST_PLATFORM__ + // Windows keeps native WebView2 hotkeys/zoom observation. Never attach to + // subframes, even on WebKit versions that inject initialization scripts there. + if (platform === 'windows' || window.top !== window) return + + let pending = Promise.resolve() + function submit(event, action) { + event.preventDefault() + event.stopImmediatePropagation() + // The host reads its current (possibly restored or menu-changed) zoom for + // every relative action. There is deliberately no renderer zoom counter. + pending = pending.then(async () => { + const internals = window.__TAURI_INTERNALS__ + if (typeof internals?.invoke !== 'function') return + await internals.invoke('owned_webview_zoom', { action }) + }).catch(() => { + // Bootstrap/untrusted navigation may have no invoke authority. Do not + // retry or fall back to the generic webview command or browser zoom. + }) + } + + window.addEventListener('keydown', (event) => { + if (event.defaultPrevented || event.altKey || event.isTrusted === false) return + if (!(platform === 'macos' ? event.metaKey : event.ctrlKey && !event.metaKey)) return + if (event.key === '=' || event.key === '+') submit(event, 'in') + else if (event.key === '-') submit(event, 'out') + else if (event.key === '0') submit(event, 'reset') + }, { capture: true }) + + let lastWheel + function wheel(event) { + if (event.defaultPrevented || event.isTrusted === false || !event.ctrlKey || event.altKey) return + const delta = Number.isFinite(event.deltaY) ? event.deltaY : -event.wheelDelta + if (!Number.isFinite(delta) || delta === 0) return + const direction = Math.sign(delta) + // Keep the pinned runtime's legacy mousewheel route for WebKit, alongside + // standard wheel. Engines emitting both for the same native gesture must + // cancel both defaults but request only one host mutation. + const duplicate = lastWheel && lastWheel.type !== event.type && + Number.isFinite(event.timeStamp) && Math.abs(event.timeStamp - lastWheel.timeStamp) <= 8 && + lastWheel.direction === direction && lastWheel.x === event.clientX && lastWheel.y === event.clientY + if (duplicate) { + event.preventDefault() + event.stopImmediatePropagation() + return + } + lastWheel = { type: event.type, timeStamp: event.timeStamp, direction, x: event.clientX, y: event.clientY } + submit(event, delta < 0 ? 'in' : 'out') + } + window.addEventListener('wheel', wheel, { capture: true, passive: false }) + window.addEventListener('mousewheel', wheel, { capture: true, passive: false }) +})() diff --git a/packages/tauri-app/src-tauri/src/window_zoom.rs b/packages/tauri-app/src-tauri/src/window_zoom.rs new file mode 100644 index 000000000..5f854c278 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/window_zoom.rs @@ -0,0 +1,309 @@ +//! Owned-window non-Windows zoom input. The generic core zoom command neither +//! updates our constraints/bookkeeping nor restricts mutation to its caller. +use serde::Deserialize; +use tauri::{AppHandle, Manager, Runtime, Url, Webview, WebviewWindowBuilder}; + +#[cfg(any(not(windows), test))] +const SCRIPT: &str = include_str!("window_zoom.js"); + +pub(crate) fn configure<'a, R: Runtime, M: Manager>( + builder: WebviewWindowBuilder<'a, R, M>, +) -> WebviewWindowBuilder<'a, R, M> { + let builder = builder.zoom_hotkeys_enabled(cfg!(windows)); + // Disabling Tauri's hotkey flag suppresses the pinned runtime's generic + // plugin:webview|set_webview_zoom script on macOS/Linux. Windows stays native. + #[cfg(not(windows))] + let builder = builder.initialization_script(render_script(std::env::consts::OS)); + builder +} + +#[cfg(any(not(windows), test))] +fn render_script(platform: &str) -> String { + SCRIPT.replace( + "__HOST_PLATFORM__", + &serde_json::to_string(platform).unwrap(), + ) +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum ZoomAction { + In, + Out, + Reset, +} + +fn next_zoom(current: f64, action: ZoomAction) -> f64 { + let current = if current.is_finite() && current > 0.0 { + current + } else { + 1.0 + }; + let next = match action { + ZoomAction::In => current + 0.2, + ZoomAction::Out => current - 0.2, + ZoomAction::Reset => 1.0, + }; + // Preserve the pinned hotkey's 20% step, within CodeNomad's saved zoom range, + // without accumulating float noise at ceil-based logical minimum boundaries. + (next.clamp(0.25, 5.0) * 1_000_000.0).round() / 1_000_000.0 +} + +#[derive(Debug, PartialEq)] +enum Owner { + Local, + Preferences, + Remote, +} + +fn owner( + label: &str, + window_label: &str, + registered_local: bool, + registered_remote: bool, +) -> Option { + if label != window_label { + return None; + } + if label == crate::preferences_window::LABEL { + return Some(Owner::Preferences); + } + if registered_local && crate::identity::local_window_id(label).is_ok() { + return Some(Owner::Local); + } + if registered_remote && label.starts_with("remote-") { + return Some(Owner::Remote); + } + None +} + +fn remote_origin_allowed(current: &Url, registered: Option<&str>) -> bool { + matches!(current.scheme(), "http" | "https") && crate::same_origin(current, registered) +} + +fn require_owned_webview(webview: &Webview, app: &AppHandle) -> Result { + let state = app.state::(); + let registered_local = app + .state::() + .record(webview.label()) + .is_some(); + let remote_origin = state + .remote_navigation + .lock() + .map_err(|e| e.to_string())? + .get(webview.label()) + .map(|metadata| metadata.origin.clone()); + match owner( + webview.label(), + webview.window().label(), + registered_local, + remote_origin.is_some(), + ) { + Some(Owner::Local | Owner::Preferences) => { + crate::require_preferences_or_local_app_webview(webview, &state) + } + Some(Owner::Remote) => { + // Do not hold any application mutex across a native URL getter. + let current = webview.url().map_err(|e| e.to_string())?; + if remote_origin_allowed(¤t, remote_origin.as_deref()) { + Ok(current) + } else { + Err("Zoom requires the registered remote renderer origin".into()) + } + } + None => Err("Zoom is limited to an owned primary application webview".into()), + } +} + +#[tauri::command] +pub(crate) async fn owned_webview_zoom( + webview: Webview, + action: ZoomAction, +) -> Result { + let app = webview.app_handle().clone(); + let admitted_url = require_owned_webview(&webview, &app)?; + let target_app = app.clone(); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + app.run_on_main_thread(move || { + let result = (|| { + // Fence queued requests across navigation/replacement, and serialize + // relative zoom reads/writes on the native thread without holding locks. + if require_owned_webview(&webview, &target_app)? != admitted_url { + return Err("Zoom renderer changed before dispatch".into()); + } + let zoom = next_zoom(crate::target_zoom(&target_app, &webview), action); + crate::set_target_zoom(&target_app, &webview, zoom); + Ok(crate::target_zoom(&target_app, &webview)) + })(); + let _ = sender.send(result); + }) + .map_err(|e| e.to_string())?; + tauri::async_runtime::spawn_blocking(move || receiver.recv()) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_uses_host_zoom_and_saved_range_not_a_bootstrap_counter() { + assert_eq!(next_zoom(1.6, ZoomAction::In), 1.8); + assert_eq!(next_zoom(1.8, ZoomAction::Out), 1.6); + assert_eq!(next_zoom(3.0, ZoomAction::Reset), 1.0); + assert_eq!(next_zoom(5.0, ZoomAction::In), 5.0); + assert_eq!(next_zoom(0.25, ZoomAction::Out), 0.25); + assert_eq!(next_zoom(f64::NAN, ZoomAction::In), 1.2); + assert_eq!( + crate::window_constraints::zoomed_minimum(next_zoom(1.0, ZoomAction::In)), + (468, 720) + ); + for invalid in ["get", "set", "zoom", "120", "minimize"] { + assert!(serde_json::from_value::(serde_json::json!(invalid)).is_err()); + } + } + + #[test] + fn only_registered_primary_webviews_have_zoom_authority() { + let local = "local-11111111-1111-4111-8111-111111111111"; + assert_eq!(owner(local, local, true, false), Some(Owner::Local)); + assert_eq!(owner(local, local, false, false), None); + assert_eq!(owner("local-fake", "local-fake", true, false), None); + assert_eq!( + owner("remote-profile", "remote-profile", false, true), + Some(Owner::Remote) + ); + assert_eq!( + owner("remote-profile", "remote-profile", false, false), + None + ); + assert_eq!( + owner("preferences", "preferences", false, false), + Some(Owner::Preferences) + ); + for label in ["browser-preview", local, "remote-profile", "preferences"] { + assert_eq!(owner(label, "different-parent", true, true), None); + } + let registered = Some("https://remote.example:8443"); + assert!(remote_origin_allowed( + &"https://remote.example:8443/app".parse().unwrap(), + registered + )); + for url in [ + "about:blank", + "tauri://localhost/loading.html", + "https://remote.example/app", + "https://other.example:8443/app", + ] { + assert!(!remote_origin_allowed(&url.parse().unwrap(), registered)); + } + assert!(!remote_origin_allowed( + &"https://remote.example:8443/app".parse().unwrap(), + None + )); + } + + #[test] + fn script_is_caller_scoped_and_windows_keeps_native_input_only() { + for platform in ["macos", "linux"] { + let script = render_script(platform); + assert!(script.contains("'owned_webview_zoom', { action }")); + assert!(!script.contains("plugin:webview|set_webview_zoom")); + assert!(!script.contains("zoomLevel")); + } + // This script is not installed on Windows, and is inert even if supplied. + assert!(SCRIPT.contains("platform === 'windows' || window.top !== window")); + } + + #[test] + fn actual_tauri_acl_keeps_zoom_narrow_and_preview_children_unprivileged() { + use std::collections::BTreeMap; + use tauri::utils::acl::{capability::Capability, manifest::Manifest, resolved::Resolved}; + let manifests: BTreeMap = + serde_json::from_str(include_str!("../gen/schemas/acl-manifests.json")).unwrap(); + let capabilities: BTreeMap = + serde_json::from_str(include_str!("../gen/schemas/capabilities.json")).unwrap(); + let source: Capability = + serde_json::from_str(include_str!("../capabilities/owned-webview-zoom.json")).unwrap(); + assert!(source.windows.is_empty()); + assert_eq!(source.webviews, ["local-*", "remote-*", "preferences"]); + assert_eq!( + serde_json::to_value(&source).unwrap(), + serde_json::to_value(&capabilities[&source.identifier]).unwrap() + ); + let resolved = Resolved::resolve( + &manifests, + capabilities, + tauri::utils::platform::Target::current(), + ) + .unwrap(); + let commands = resolved + .allowed_commands + .keys() + .cloned() + .collect::>(); + let authority = tauri::runtime_authority!(manifests, resolved); + for (label, url) in [ + ( + "local-11111111-1111-4111-8111-111111111111", + "http://127.0.0.1:32123/app", + ), + ("preferences", "http://127.0.0.1:32123/preferences"), + ("remote-profile", "https://remote.example:8443/app"), + ] { + let origin = tauri::ipc::Origin::Remote { + url: url.parse().unwrap(), + }; + assert!(authority + .resolve_access("owned_webview_zoom", label, label, &origin) + .is_some()); + assert!( + authority + .resolve_access("plugin:webview|set_webview_zoom", label, label, &origin) + .is_none(), + "generic zoom granted to {label}" + ); + for command in &commands { + assert!( + authority + .resolve_access(command, label, "browser-preview", &origin) + .is_none(), + "preview under {label} inherited {command}" + ); + } + if label.starts_with("remote-") { + for command in [ + "cli_restart", + "client_state_load", + "browser_target_register", + "window_control", + "plugin:dialog|open", + ] { + assert!( + authority + .resolve_access(command, label, label, &origin) + .is_none(), + "remote gained {command}" + ); + } + assert!(authority + .resolve_access("plugin:notification|notify", label, label, &origin) + .is_some()); + } + } + let origin = tauri::ipc::Origin::Local; + assert!(authority + .resolve_access( + "owned_webview_zoom", + "local-11111111-1111-4111-8111-111111111111", + "local-11111111-1111-4111-8111-111111111111", + &origin + ) + .is_some()); + assert!(authority + .resolve_access("owned_webview_zoom", "unknown", "unknown", &origin) + .is_none()); + } +} diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index 9377dd869..1effb214f 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -16,7 +16,8 @@ "capabilities": [ "main-window-native-dialogs", "preferences-window", - "remote-window-notifications" + "remote-window-notifications", + "owned-webview-zoom" ] } }, diff --git a/packages/tauri-app/tests/macos-window/Cargo.toml b/packages/tauri-app/tests/macos-window/Cargo.toml new file mode 100644 index 000000000..c73804591 --- /dev/null +++ b/packages/tauri-app/tests/macos-window/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "codenomad-macos-window-fixture" +version = "0.1.0" +edition = "2021" +publish = false + +[target.'cfg(target_os = "macos")'.dependencies] +tao = "=0.34.6" diff --git a/packages/tauri-app/tests/macos-window/src/main.rs b/packages/tauri-app/tests/macos-window/src/main.rs new file mode 100644 index 000000000..49f9b54e2 --- /dev/null +++ b/packages/tauri-app/tests/macos-window/src/main.rs @@ -0,0 +1,134 @@ +// Real AppKit/Tao regression, with no CodeNomad profile, backend or OpenCode. +#[cfg(target_os = "macos")] +fn main() { + use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, + }; + use tao::{ + dpi::LogicalSize, + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, + }; + + let progress = Arc::new((AtomicUsize::new(0), AtomicUsize::new(0))); + let watchdog = progress.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(20)); + let phase = watchdog.0.load(Ordering::SeqCst); + let captures = watchdog.1.load(Ordering::SeqCst); + if phase == 1 && captures >= 100 { + eprintln!("FAIL: getter event feedback watchdog: phase={phase} captures={captures}"); + } else { + eprintln!("FAIL: unrelated native window timeout: phase={phase} captures={captures}"); + } + std::process::exit(2); + }); + let event_loop = EventLoop::new(); + let window = WindowBuilder::new() + .with_title("CodeNomad isolated macOS window regression") + .with_decorations(false) + .with_inner_size(LogicalSize::new(600.0, 500.0)) + .build(&event_loop) + .unwrap(); + let mut phase = 0; + let mut deadline = Instant::now() + Duration::from_secs(1); + let mut query_during_events = false; + let mut events = 0; + let mut before_reads = 0; + let mut normal_size = window.inner_size(); + let mut normal_position = window.outer_position().unwrap(); + event_loop.run(move |event, _, flow| { + *flow = ControlFlow::WaitUntil(deadline); + match event { + Event::WindowEvent { + event: WindowEvent::Resized(_) | WindowEvent::Moved(_), + .. + } => { + events += 1; + if query_during_events { + progress.1.fetch_add(1, Ordering::SeqCst); + // Same feedback boundary as production geometry capture. + let _ = window.is_maximized(); + } + } + Event::MainEventsCleared if Instant::now() >= deadline => { + match phase { + 0 => { + println!("BEGIN: borderless getter feedback probe"); + progress.0.store(1, Ordering::SeqCst); + normal_size = window.inner_size(); + normal_position = window.outer_position().unwrap(); + assert!(!window.is_maximized()); + query_during_events = true; + before_reads = events; + for _ in 0..100 { + assert!(!window.is_maximized()); + } + } + 1 => { + progress.0.store(2, Ordering::SeqCst); + assert_eq!(events, before_reads, "normal reads generated geometry events"); + assert_eq!(window.inner_size(), normal_size); + assert_eq!(window.outer_position().unwrap(), normal_position); + window.set_maximized(true); + } + 2 => { + assert!(window.is_maximized(), "maximize must remain functional"); + before_reads = events; + for _ in 0..100 { + assert!(window.is_maximized()); + } + } + 3 => { + assert_eq!(events, before_reads, "maximized reads generated geometry events"); + window.set_maximized(false); + } + 4 => { + assert!(!window.is_maximized()); + assert_eq!(window.inner_size(), normal_size, "restore lost normal bounds"); + assert_eq!(window.outer_position().unwrap(), normal_position); + window.set_resizable(false); + } + 5 => { + before_reads = events; + for _ in 0..100 { + assert!(!window.is_maximized()); + } + } + 6 => { + assert_eq!(events, before_reads, "fixed-size reads generated geometry events"); + window.set_resizable(true); + window.set_decorations(true); + } + 7 => { + before_reads = events; + for _ in 0..100 { + assert!(!window.is_maximized()); + } + } + 8 => { + assert_eq!(events, before_reads, "decorated reads generated geometry events"); + println!("PASS: read-only zoom checks, native event capture, maximize/restore and fixed-size/decorated windows"); + *flow = ControlFlow::Exit; + return; + } + _ => unreachable!(), + } + phase += 1; + deadline = Instant::now() + Duration::from_secs(1); + } + _ => {} + } + }); +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("Run this native regression on macOS with a graphical session."); + std::process::exit(1); +} diff --git a/packages/tauri-app/tests/window-zoom-script.test.mjs b/packages/tauri-app/tests/window-zoom-script.test.mjs new file mode 100644 index 000000000..5ffcb6802 --- /dev/null +++ b/packages/tauri-app/tests/window-zoom-script.test.mjs @@ -0,0 +1,176 @@ +// Runs the actual shipped initialization script, not a rewritten input handler. +// No browser, application profile, backend or OpenCode daemon is started. +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import vm from "node:vm" +import test from "node:test" +import { fileURLToPath } from "node:url" + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const shipped = fs.readFileSync(path.join(root, "src-tauri/src/window_zoom.js"), "utf8") +const lock = fs.readFileSync(path.join(root, "Cargo.lock"), "utf8") +const version = lock.match(/name = "tauri"\r?\nversion = "([^"]+)"/)[1] +const registry = path.join(process.env.CARGO_HOME ?? path.join(process.env.USERPROFILE ?? process.env.HOME, ".cargo"), "registry/src") +const pinnedPath = fs.readdirSync(registry).map((entry) => path.join(registry, entry, `tauri-${version}/src/webview/scripts/zoom-hotkey.js`)).find(fs.existsSync) +assert.ok(pinnedPath, `installed pinned Tauri ${version} script is required`) +const pinned = fs.readFileSync(pinnedPath, "utf8") + +function harness(platform, { source = shipped, zoom = 1, context = "local", child = false, internals = true, reject = false } = {}) { + const listeners = new Map() + const packets = [] + const state = { zoom, minimum: context === "preferences" ? [760, 560] : [Math.ceil(390 * zoom), Math.ceil(600 * zoom)], persisted: zoom, inFlight: 0, maxInFlight: 0 } + const window = { + addEventListener(type, handler, options) { + const list = listeners.get(type) ?? [] + list.push({ handler, options }) + listeners.set(type, list) + }, + } + window.top = child ? {} : window + if (internals) window.__TAURI_INTERNALS__ = { + async invoke(command, payload) { + packets.push({ command, ...payload }) + state.maxInFlight = Math.max(state.maxInFlight, ++state.inFlight) + try { + await new Promise((resolve) => setImmediate(resolve)) + if (reject) throw new Error("no invoke authority") + if (command === "plugin:webview|set_webview_zoom") { + // The pinned path changes native zoom, but bypasses native host + // bookkeeping/constraints (and lacks remote permission). + if (context === "remote") throw new Error("generic core command denied") + state.zoom = payload.value + return + } + assert.equal(command, "owned_webview_zoom") + assert.deepEqual(Object.keys(payload), ["action"]) + const next = payload.action === "reset" ? 1 : state.zoom + (payload.action === "in" ? .2 : -.2) + state.zoom = Math.round(Math.min(5, Math.max(.25, next)) * 1e6) / 1e6 + state.persisted = state.zoom + if (context !== "preferences") state.minimum = [Math.ceil(390 * state.zoom), Math.ceil(600 * state.zoom)] + return state.zoom + } finally { state.inFlight-- } + }, + } + const script = source.replace("__HOST_PLATFORM__", JSON.stringify(platform)).replace("__TEMPLATE_os_name__", JSON.stringify(platform)) + vm.runInNewContext(script, { window, Promise, Number, Math }, { filename: source === shipped ? "window_zoom.js" : `tauri-${version}/zoom-hotkey.js` }) + let clock = 100 + function emit(type, values = {}) { + const event = { type, isTrusted: true, key: "", ctrlKey: false, metaKey: false, altKey: false, deltaY: 0, clientX: 30, clientY: 50, timeStamp: clock += 20, defaultPrevented: false, stopped: false, + preventDefault() { this.defaultPrevented = true }, + stopImmediatePropagation() { this.stopped = true }, ...values } + for (const listener of listeners.get(type) ?? []) { listener.handler(event); if (event.stopped) break } + return event + } + async function flush() { + for (let count = 0; count < 40; count++) await new Promise((resolve) => setImmediate(resolve)) + assert.equal(state.inFlight, 0) + } + return { emit, flush, state, packets, listeners, window } +} + +for (const platform of ["macos", "linux"]) { + const modifier = platform === "macos" ? { metaKey: true } : { ctrlKey: true } + + test(`${platform}: pinned ${version} script reproduces stale CSS minimum at 120%`, async () => { + const h = harness(platform, { source: pinned }) + h.emit("keydown", { key: "+", ...modifier }) + await h.flush() + assert.equal(h.state.zoom, 1.2) + assert.deepEqual(h.state.minimum, [390, 600]) + assert.deepEqual(h.state.minimum.map((value) => value / h.state.zoom), [325, 500]) + }) + + for (const context of ["local", "remote", "preferences"]) { + test(`${platform}/${context}: actual owned script starts at restored host zoom and tracks gestures`, async () => { + const h = harness(platform, { context, zoom: 1.6 }) + const plus = h.emit("keydown", { key: "=", ...modifier }) + assert.ok(plus.defaultPrevented && plus.stopped) + await h.flush() + assert.equal(h.state.zoom, 1.8) + assert.equal(h.state.persisted, 1.8) + assert.deepEqual(h.state.minimum, context === "preferences" ? [760, 560] : [702, 1080]) + // Native menu changes between gestures must not be overwritten by a JS counter. + h.state.zoom = 2.5 + h.emit("keydown", { key: "-", ...modifier }) + await h.flush() + assert.equal(h.state.zoom, 2.3) + const wheel = h.emit("mousewheel", { ctrlKey: true, deltaY: -120 }) + assert.ok(wheel.defaultPrevented && wheel.stopped) + await h.flush() + assert.equal(h.state.zoom, 2.5) + h.emit("wheel", { ctrlKey: true, deltaY: 120 }) + await h.flush() + assert.equal(h.state.zoom, 2.3) + h.emit("keydown", { key: "0", ...modifier }) + await h.flush() + assert.equal(h.state.zoom, 1) + assert.deepEqual(h.state.minimum, context === "preferences" ? [760, 560] : [390, 600]) + assert.ok(h.packets.every((p) => p.command === "owned_webview_zoom" && !("label" in p) && !("value" in p))) + }) + } + + test(`${platform}: standard/legacy paired wheel events mutate once, in either order`, async () => { + const h = harness(platform) + for (const [first, second, timeStamp] of [["wheel", "mousewheel", 100], ["mousewheel", "wheel", 200]]) { + assert.ok(h.emit(first, { ctrlKey: true, deltaY: -120, timeStamp }).defaultPrevented) + assert.ok(h.emit(second, { ctrlKey: true, deltaY: -100, timeStamp: timeStamp + 1 }).defaultPrevented) + } + await h.flush() + assert.equal(h.packets.length, 2) + assert.equal(h.state.zoom, 1.4) + for (const type of ["wheel", "mousewheel"]) assert.equal(h.listeners.get(type)[0].options.passive, false) + }) + + test(`${platform}: rapid input serializes; bounds and native defaults are not doubled`, async () => { + const h = harness(platform, { zoom: 4.8 }) + for (const key of ["+", "+", "-", "0", "-"]) h.emit("keydown", { key, ...modifier }) + await h.flush() + assert.equal(h.state.maxInFlight, 1) + assert.equal(h.state.zoom, .8) + h.state.zoom = .25 + h.emit("mousewheel", { ctrlKey: true, deltaY: 120 }) + await h.flush() + assert.equal(h.state.zoom, .25) + }) + + test(`${platform}: unrelated, synthetic and handled input stays untouched`, async () => { + const h = harness(platform) + for (const values of [{ key: "+" }, { key: "x", ...modifier }, { key: "+", ...modifier, altKey: true }, { key: "+", ...modifier, isTrusted: false }]) { + assert.equal(h.emit("keydown", values).defaultPrevented, false) + } + assert.equal(h.emit("wheel", { deltaY: -120 }).defaultPrevented, false) + assert.equal(h.emit("wheel", { ctrlKey: true, deltaY: 0 }).defaultPrevented, false) + await h.flush() + assert.equal(h.packets.length, 0) + }) + + test(`${platform}: bootstrap/denied invoke fails closed without replay or generic fallback`, async () => { + for (const options of [{ internals: false }, { reject: true }]) { + const h = harness(platform, options) + assert.ok(h.emit("keydown", { key: "+", ...modifier }).defaultPrevented) + await h.flush() + assert.equal(h.state.zoom, 1) + assert.ok(h.packets.length <= 1) + assert.ok(h.packets.every((p) => p.command === "owned_webview_zoom")) + } + const h = harness(platform, { reject: true }) + h.emit("keydown", { key: "+", ...modifier }) + await h.flush() + // A later fresh gesture can succeed; the failed mutation is never replayed. + h.window.__TAURI_INTERNALS__.invoke = async (command, payload) => h.packets.push({ command, ...payload }) + h.emit("keydown", { key: "-", ...modifier }) + await h.flush() + assert.deepEqual(h.packets.map((p) => p.action), ["in", "out"]) + }) +} + +test("Windows and preview subframes get no custom input listener", async () => { + for (const h of [harness("windows"), harness("linux", { child: true }), harness("macos", { child: true })]) { + assert.equal(h.listeners.size, 0) + h.emit("keydown", { key: "+", ctrlKey: true, metaKey: true }) + h.emit("mousewheel", { ctrlKey: true, deltaY: -120 }) + await h.flush() + assert.equal(h.packets.length, 0) + } +}) diff --git a/packages/tauri-app/vendor/README.md b/packages/tauri-app/vendor/README.md index 1dc32e1d9..f18b5832e 100644 --- a/packages/tauri-app/vendor/README.md +++ b/packages/tauri-app/vendor/README.md @@ -1,7 +1,8 @@ -# Tao Windows input deadlock backport +# Tao native window backports `tao-0.34.6/` is the published Apache-2.0 crate (license included), with only -the four Windows source files from upstream [tao#1215] changed. It is selected +the four Windows source files from upstream [tao#1215] and the macOS +`is_zoomed` function from [tao#1182] changed. It is selected by the Tauri workspace's `[patch.crates-io]`; there is no registry-cache mutation or build-time patching. All platforms keep the same dependency/API version. @@ -30,4 +31,27 @@ Remove this override when the supported Tauri dependency range can resolve a published Tao release containing the fix; run the same regression first. Merely bumping within the unfixed 0.34.x/0.35.x release lines is insufficient. +## macOS read-only zoom detection + +The macOS backport is the exact `window.rs` hunk from upstream commit +`2fb512315899973e2c64d00c0e34ed382d93c893` ([tao#1182]). Its patch is +`patches/tao-1182.patch`. Windows and Linux behavior is unchanged. + +On borderless windows, the old maximize getter temporarily changed the native +style mask twice. These writes generate resize/move events. CodeNomad's geometry +capture reads the getter again, sustaining a main-thread event feedback loop +before loading-screen navigation completes. The upstream getter uses read-only +AppKit frame/screen queries instead. It retains native `isZoomed` for ordinary +titled, resizable windows and when no screen is available. + +Run `node scripts/test-tauri-macos-window.mjs --baseline` in a graphical macOS +session. The isolated native fixture checks that getters generate no geometry +events, including reads during geometry capture, and exercises maximize/restore, +fixed-size and decorated windows. Its original-crate negative control reproduces +the feedback loop. See `dev-docs/TAURI_MACOS_STARTUP.md` for real application tests. + +Remove the override only after both native backports are available in the +resolved supported release and both native regressions pass. + [tao#1215]: https://github.com/tauri-apps/tao/pull/1215 +[tao#1182]: https://github.com/tauri-apps/tao/pull/1182 diff --git a/packages/tauri-app/vendor/patches/tao-1182.patch b/packages/tauri-app/vendor/patches/tao-1182.patch new file mode 100644 index 000000000..6d94cb718 --- /dev/null +++ b/packages/tauri-app/vendor/patches/tao-1182.patch @@ -0,0 +1,54 @@ +From: https://github.com/tauri-apps/tao/commit/2fb512315899973e2c64d00c0e34ed382d93c893 +Subject: fix(macos): remove synchronous styleMask mutations in is_zoomed (#1182) + +Exact upstream source hunk; the upstream .changes entry is not vendored. + +diff --git a/src/platform_impl/macos/window.rs b/src/platform_impl/macos/window.rs +--- a/src/platform_impl/macos/window.rs ++++ b/src/platform_impl/macos/window.rs +@@ -974,24 +974,30 @@ impl UnownedWindow { + } + + pub(crate) fn is_zoomed(&self) -> bool { +- // because `isZoomed` doesn't work if the window's borderless, +- // we make it resizable temporalily. +- let curr_mask = unsafe { self.ns_window.styleMask() }; +- +- let required = NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable; +- let needs_temp_mask = !curr_mask.contains(required); +- if needs_temp_mask { +- self.set_style_mask_sync(required); +- } ++ // Previously, is_zoomed temporarily mutated the window's styleMask(or resizable) ++ // to force macOS to return a valid result for borderless windows. ++ // This synchronous mutation could trigger unnecessary layout passes or state inconsistencies. ++ // Related issue: https://github.com/rust-windowing/winit/issues/4071 and https://github.com/tauri-apps/plugins-workspace/issues/3240 + +- let is_zoomed: bool = unsafe { self.ns_window.isZoomed() }; ++ unsafe { ++ let curr_mask = self.ns_window.styleMask(); + +- // Roll back temp styles +- if needs_temp_mask { +- self.set_style_mask_sync(curr_mask); +- } ++ let required = NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable; ++ if curr_mask.contains(required) { ++ return self.ns_window.isZoomed(); ++ } + +- is_zoomed ++ if let Some(screen) = self.ns_window.screen() { ++ let frame = self.ns_window.frame(); ++ let visible_frame = screen.visibleFrame(); ++ ++ return (frame.size.width - visible_frame.size.width).abs() < 1.0 ++ && (frame.size.height - visible_frame.size.height).abs() < 1.0; ++ } ++ ++ // Fallback to original `isZoomed` check ++ self.ns_window.isZoomed() ++ } + } + + fn saved_style(&self, shared_state: &mut SharedState) -> NSWindowStyleMask { diff --git a/packages/tauri-app/vendor/tao-0.34.6/src/platform_impl/macos/window.rs b/packages/tauri-app/vendor/tao-0.34.6/src/platform_impl/macos/window.rs index 54268dcf4..8a19b9d2c 100644 --- a/packages/tauri-app/vendor/tao-0.34.6/src/platform_impl/macos/window.rs +++ b/packages/tauri-app/vendor/tao-0.34.6/src/platform_impl/macos/window.rs @@ -974,24 +974,30 @@ impl UnownedWindow { } pub(crate) fn is_zoomed(&self) -> bool { - // because `isZoomed` doesn't work if the window's borderless, - // we make it resizable temporalily. - let curr_mask = unsafe { self.ns_window.styleMask() }; + // Previously, is_zoomed temporarily mutated the window's styleMask(or resizable) + // to force macOS to return a valid result for borderless windows. + // This synchronous mutation could trigger unnecessary layout passes or state inconsistencies. + // Related issue: https://github.com/rust-windowing/winit/issues/4071 and https://github.com/tauri-apps/plugins-workspace/issues/3240 - let required = NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable; - let needs_temp_mask = !curr_mask.contains(required); - if needs_temp_mask { - self.set_style_mask_sync(required); + unsafe { + let curr_mask = self.ns_window.styleMask(); + + let required = NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable; + if curr_mask.contains(required) { + return self.ns_window.isZoomed(); + } + + if let Some(screen) = self.ns_window.screen() { + let frame = self.ns_window.frame(); + let visible_frame = screen.visibleFrame(); + + return (frame.size.width - visible_frame.size.width).abs() < 1.0 + && (frame.size.height - visible_frame.size.height).abs() < 1.0; + } + + // Fallback to original `isZoomed` check + self.ns_window.isZoomed() } - - let is_zoomed: bool = unsafe { self.ns_window.isZoomed() }; - - // Roll back temp styles - if needs_temp_mask { - self.set_style_mask_sync(curr_mask); - } - - is_zoomed } fn saved_style(&self, shared_state: &mut SharedState) -> NSWindowStyleMask { diff --git a/packages/ui/package.json b/packages/ui/package.json index c53505ac9..570efdd0d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,7 +14,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode/client": "2.0.18", + "@opencode/client": "2.0.21", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/scripts/monaco-public-assets.js b/packages/ui/scripts/monaco-public-assets.js index 6ee3eaf73..e5e72bbfb 100644 --- a/packages/ui/scripts/monaco-public-assets.js +++ b/packages/ui/scripts/monaco-public-assets.js @@ -77,13 +77,9 @@ export function copyMonacoPublicAssets(params) { } } - // Copy baseline basic tokenizers. - for (const lang of ["python", "markdown", "cpp", "kotlin"]) { - const src = resolve(sourceRoot, "basic-languages", lang) - if (fs.existsSync(src)) { - copyRecursive(src, resolve(destRoot, "basic-languages", lang)) - } - } + // Workspace previews may open any supported language, including YAML and + // TypeScript. Keep tokenizers local so source views work without a CDN. + copyRecursive(resolve(sourceRoot, "basic-languages"), resolve(destRoot, "basic-languages")) // Copy monaco.contribution.js entrypoints (needed by some loads). const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js") diff --git a/packages/ui/src/components/browser-frame.tsx b/packages/ui/src/components/browser-frame.tsx index cb905efef..86fab340d 100644 --- a/packages/ui/src/components/browser-frame.tsx +++ b/packages/ui/src/components/browser-frame.tsx @@ -1,8 +1,12 @@ import { ArrowLeft, ArrowRight, ChevronDown, Expand, MessageSquarePlus, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" import { Show, createEffect, createMemo, createSignal, onCleanup, untrack, type Component } from "solid-js" import { runtimeEnv } from "../lib/runtime-env" +import { useI18n } from "../lib/i18n" +import emulationProfiles from "../lib/native/browser-emulation.json" +import type { BrowserEmulationPreset } from "../lib/native/browser-emulation" import { controlTauriBrowserTarget, + emulateBrowserTarget, nativeBrowserHost, onTauriBrowserNavigation, physicalBrowserBounds, @@ -11,6 +15,7 @@ import { updateTauriBrowserTarget, } from "../lib/native/browser" import { getBrowserFramePolicy, normalizeBrowserPreviewUrl } from "./browser-frame-security" +import WindowCloseButton from "./window-close-button" export interface BrowserFrameElementTarget { pagePath: string @@ -45,8 +50,8 @@ const VIEWPORT_PRESETS: Record void sessionId?: string title: string initialUrl: string @@ -108,6 +114,9 @@ function getElementSelector(element: Element): string { } export const BrowserFrame: Component = (props) => { + const { t } = useI18n() + const [emulationBusy, setEmulationBusy] = createSignal(false) + const [emulationReady, setEmulationReady] = createSignal(false) const [frameSrc, setFrameSrc] = createSignal(props.initialUrl) const [pathInput, setPathInput] = createSignal(props.initialAddress ?? "/") const [viewportPreset, setViewportPreset] = createSignal("responsive") @@ -131,6 +140,8 @@ export const BrowserFrame: Component = (props) => { // Iframe previews also run on HTTP LAN origins without crypto.randomUUID. let browserRegistrationId = nativeBrowserAvailable ? crypto.randomUUID() : "" let disposed = false + let appliedEmulation: { registrationId: string; preset: BrowserEmulationPreset } | undefined + let emulationGeneration = 0 const framePolicy = getBrowserFramePolicy(runtimeEnv) const canComment = createMemo(() => !nativeMode() && (framePolicy.canInspectDom || props.commentBridge) && Boolean(props.onToggleCommentMode && props.onCommentTarget)) @@ -138,6 +149,43 @@ export const BrowserFrame: Component = (props) => { const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive") const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0]) + const emulationProfile = (preset: BrowserViewportPreset): BrowserEmulationPreset => + preset === "mobile" || preset === "mobileLandscape" ? preset : "none" + const applyEmulation = async (registrationId: string, preset: BrowserEmulationPreset) => { + await emulateBrowserTarget(registrationId, preset) + if (!disposed && registrationId === browserRegistrationId) appliedEmulation = { registrationId, preset } + } + const restoreEmulation = async (registrationId: string) => { + // Readiness means we own a registered target, not that a profile succeeded. + // Keep failures retryable, including selecting the same retained template. + setEmulationReady(true) + const profile = untrack(() => emulationProfile(viewportPreset())) + if (profile === "none") return + const generation = ++emulationGeneration + setEmulationBusy(true) + try { await applyEmulation(registrationId, profile) } + catch (error) { + if (!disposed && registrationId === browserRegistrationId) reportNativeError(error) + } finally { + if (!disposed && generation === emulationGeneration) setEmulationBusy(false) + } + } + const selectViewport = async (preset: BrowserViewportPreset) => { + if (emulationBusy()) return + setViewportMenuOpen(false) + const previous = appliedEmulation?.registrationId === browserRegistrationId ? appliedEmulation.preset : undefined + const next = emulationProfile(preset) + if (previous === next || !nativeMode()) { setViewportPreset(preset); return } + setEmulationBusy(true) + const generation = ++emulationGeneration + const registrationId = browserRegistrationId + try { + await applyEmulation(registrationId, next) + if (!disposed && registrationId === browserRegistrationId) setViewportPreset(preset) + } catch (error) { if (registrationId === browserRegistrationId) reportNativeError(error) } + finally { if (!disposed && generation === emulationGeneration) setEmulationBusy(false) } + } + const getEditablePathFromUrl = (url: string): string => { try { const parsed = new URL(url, window.location.origin) @@ -349,6 +397,7 @@ export const BrowserFrame: Component = (props) => { const sessionId = props.sessionId if (!webviewReady || !sessionId || registering) return if (registered && registeredSessionId !== sessionId) { + setEmulationReady(false) const previousId = browserRegistrationId browserRegistrationId = crypto.randomUUID() registering = true @@ -362,6 +411,7 @@ export const BrowserFrame: Component = (props) => { } if (visible === registered) return if (!visible) { + setEmulationReady(false) if (retryTimer) clearTimeout(retryTimer) retryTimer = undefined if (registered) { @@ -384,7 +434,7 @@ export const BrowserFrame: Component = (props) => { sessionId, registrationId, guestWebContentsId: webview.getWebContentsId(), - }).then(() => { + }).then(async () => { registering = false if (!active) { void window.electronAPI?.unregisterBrowserTarget?.(registrationId).catch(reportNativeError) @@ -392,6 +442,7 @@ export const BrowserFrame: Component = (props) => { } registered = true registeredSessionId = sessionId + await restoreEmulation(registrationId) registrationFailures = 0 registrationErrorReported = false syncRegistration() @@ -414,6 +465,7 @@ export const BrowserFrame: Component = (props) => { webview.addEventListener("dom-ready", handleReady) syncRegistration() cleanupWebviewListeners = () => { + setEmulationReady(false) active = false if (retryTimer) clearTimeout(retryTimer) webview.removeEventListener("did-navigate", syncLocation) @@ -487,6 +539,7 @@ export const BrowserFrame: Component = (props) => { nativeVisible = false registeredSessionId = "" tauriRegistered = false + setEmulationReady(false) void unregisterTauriBrowserTarget(previousId).catch(reportNativeError).finally(() => { registering = false if (active) syncBounds() @@ -537,6 +590,7 @@ export const BrowserFrame: Component = (props) => { registered = true nativeVisible = true tauriRegistered = true + await restoreEmulation(registrationId) syncErrorReported = false if (nativeTarget() !== target) { await controlTauriBrowserTarget(registrationId, "navigate", nativeTarget()) @@ -679,7 +733,7 @@ export const BrowserFrame: Component = (props) => { return (
-
+
@@ -709,7 +763,7 @@ export const BrowserFrame: Component = (props) => { -
+
= (props) => { }) function handleKeyDown(e: KeyboardEvent) { + // Header controls and focused result buttons retain native activation. + if (e.target !== inputRef) return const ordered = orderedCommands() if (ordered.length === 0) { @@ -221,6 +224,7 @@ const CommandPalette: Component = (props) => { open={props.open} onClose={props.onClose} title={t("commandPalette.title")} + initialFocus={() => inputRef} description={t("commandPalette.description")} class="fixed top-[20vh] left-1/2 -translate-x-1/2 z-50 w-[calc(100%-2rem)] max-w-2xl max-h-[60vh]" onKeyDown={handleKeyDown} @@ -245,8 +249,9 @@ const CommandPalette: Component = (props) => { setSelectedCommandId(null) }} placeholder={t("commandPalette.searchPlaceholder")} - class="modal-search-input" + class="modal-search-input min-w-0" /> +
diff --git a/packages/ui/src/components/directory-browser-address.tsx b/packages/ui/src/components/directory-browser-address.tsx index b3b9387b4..71750d00e 100644 --- a/packages/ui/src/components/directory-browser-address.tsx +++ b/packages/ui/src/components/directory-browser-address.tsx @@ -1,11 +1,11 @@ import { Component, For, Show, createEffect, createSignal, createUniqueId } from "solid-js" -import { ArrowLeft, ArrowUpLeft, Folder, Home } from "lucide-solid" +import { ArrowLeft, Folder, FolderRoot, Home } from "lucide-solid" import { useI18n } from "../lib/i18n" export interface DirectoryDestination { target: string label: string - kind: "initial" | "parent" | "home" | "folder" + kind: "initial" | "root" | "home" | "folder" } interface DirectoryBrowserAddressProps { @@ -92,7 +92,7 @@ const DirectoryBrowserAddress: Component = (props) onClick={() => { props.onChoose(destination); props.onOpenChange(false); setActive(-1) }} > {destination.kind === "initial" ? - : destination.kind === "parent" ? + : destination.kind === "root" ? : destination.kind === "home" ? : } {destination.label} diff --git a/packages/ui/src/components/directory-browser-dialog.tsx b/packages/ui/src/components/directory-browser-dialog.tsx index d7379c5e7..904276155 100644 --- a/packages/ui/src/components/directory-browser-dialog.tsx +++ b/packages/ui/src/components/directory-browser-dialog.tsx @@ -1,5 +1,5 @@ import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" -import { ArrowRightSquare, File as FileIcon, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid" +import { ArrowRightSquare, ArrowUpLeft, File as FileIcon, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid" import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types" import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" @@ -263,6 +263,7 @@ const DirectoryBrowserDialog: Component = (props) = }) function handleNavigateTo(path: string) { + setAddressOpen(false) setPathInputDirty(false) void navigateTo(path) } @@ -298,13 +299,12 @@ const DirectoryBrowserDialog: Component = (props) = const name = initial.split(/[\\/]/).filter(Boolean).at(-1) ?? initial add({ target: initial, label: t("directoryBrowser.goToInitial", { name }), kind: "initial" }) } + if (meta.rootPath) { + add({ target: meta.rootPath, label: t("directoryBrowser.goToRoot"), kind: "root" }) + } if (meta.scope === "unrestricted" && meta.homePath) { add({ target: meta.homePath, label: t("directoryBrowser.goToHome"), kind: "home" }) } - if (meta.parentPath) { - const parent = meta.pathKind === "relative" ? resolveAbsolutePath(meta.rootPath, meta.parentPath) : meta.parentPath - add({ target: parent, label: t("directoryBrowser.goToParent"), kind: "parent" }) - } // Use the already loaded listing; typing never starts a speculative filesystem request. const input = pathInput().trim().replace(/\\/g, "/") const normalizedCurrent = current.replace(/\\/g, "/") @@ -493,10 +493,27 @@ const DirectoryBrowserDialog: Component = (props) = } > 0} + when={folderRows().length > 0 || Boolean(currentMetadata()?.parentPath)} fallback={
{t("directoryBrowser.noFolders")}
} >
+ + {(parent) => ( +
+
+ {/* Keep address blur from reflowing the list before the click lands. */} + +
+
+ )} +
{(entry) => { const isFolder = entry.type === "directory" diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx index 6d4fda3d1..54d96699e 100644 --- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -3,7 +3,7 @@ import { loadMonaco } from "../../lib/monaco/setup" import { getOrCreateTextModel } from "../../lib/monaco/model-cache" import { inferMonacoLanguageId } from "../../lib/monaco/language" import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup" -import { useTheme } from "../../lib/theme" +import { useMonacoTheme } from "../../lib/monaco/theme" import { parsePatchToBeforeAfter } from "../../lib/diff-utils" interface MonacoDiffViewerProps { @@ -67,13 +67,13 @@ function getSplitGutterSizing(options: { before: string; after: string }) { } export function MonacoDiffViewer(props: MonacoDiffViewerProps) { - const { isDark } = useTheme() let host: HTMLDivElement | undefined let diffEditor: any = null let monaco: any = null let splitLayoutFrame: number | null = null const [ready, setReady] = createSignal(false) + useMonacoTheme(() => ready() ? monaco : null) const [hoveredLine, setHoveredLine] = createSignal(null) const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null) const [widgetHovered, setWidgetHovered] = createSignal(false) @@ -194,9 +194,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { if (cancelled) return if (!host || !monaco) return - monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") diffEditor = monaco.editor.createDiffEditor(host, { readOnly: true, + // Read-only previews need selection, not cross-model symbol requests. + // Monaco 0.52 leaks their cancellation on rapid model replacement. + occurrencesHighlight: "off", automaticLayout: true, renderSideBySide: true, renderSideBySideInlineBreakpoint: 0, @@ -235,11 +237,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { }) }) - createEffect(() => { - if (!ready() || !monaco || !diffEditor) return - monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") - }) - createEffect(() => { if (!host) return host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified" diff --git a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx index 089d85997..dc1b6b8d7 100644 --- a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx @@ -3,12 +3,13 @@ import { loadMonaco } from "../../lib/monaco/setup" import { getOrCreateTextModel } from "../../lib/monaco/model-cache" import { inferMonacoLanguageId } from "../../lib/monaco/language" import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup" -import { useTheme } from "../../lib/theme" +import { useMonacoTheme } from "../../lib/monaco/theme" interface MonacoFileViewerProps { scopeKey: string path: string content: string + readOnly?: boolean wordWrap?: "on" | "off" compactGutter?: boolean onSave?: (content: string) => void @@ -16,12 +17,12 @@ interface MonacoFileViewerProps { } export function MonacoFileViewer(props: MonacoFileViewerProps) { - const { isDark } = useTheme() let host: HTMLDivElement | undefined let editor: any = null let monaco: any = null const [ready, setReady] = createSignal(false) + useMonacoTheme(() => ready() ? monaco : null) const disposeEditor = () => { try { @@ -55,11 +56,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { if (cancelled) return if (!host || !monaco) return - monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") editor = monaco.editor.create(host, { value: "", language: "plaintext", - readOnly: false, + readOnly: props.readOnly ?? false, + occurrencesHighlight: props.readOnly ? "off" : "singleFile", automaticLayout: true, lineNumbers: "on", lineNumbersMinChars: lineNumbersMinChars(props.content), @@ -91,11 +92,6 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { }) }) - createEffect(() => { - if (!ready() || !monaco || !editor) return - monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") - }) - createEffect(() => { if (!ready() || !editor) return editor.updateOptions({ wordWrap: props.wordWrap === "on" ? "on" : "off" }) diff --git a/packages/ui/src/components/files-preview-view.tsx b/packages/ui/src/components/files-preview-view.tsx new file mode 100644 index 000000000..5183af0e9 --- /dev/null +++ b/packages/ui/src/components/files-preview-view.tsx @@ -0,0 +1,10 @@ +import { Show } from "solid-js" +import type { FilePreviewTarget } from "../stores/files-preview" +import { GitDiffView } from "./git-diff-view" +import { WorkspaceFileView } from "./workspace-file-view" + +export function FilesPreviewView(props: { instanceId: string; target: FilePreviewTarget; active: boolean; onClose: () => void; onInsertComment?: (text: string) => void }) { + return }> + + +} diff --git a/packages/ui/src/components/git-diff-view.tsx b/packages/ui/src/components/git-diff-view.tsx new file mode 100644 index 000000000..52845e26b --- /dev/null +++ b/packages/ui/src/components/git-diff-view.tsx @@ -0,0 +1,95 @@ +import { Show, Suspense, createEffect, createSignal, lazy, on, onCleanup } from "solid-js" +import { WrapText, X } from "lucide-solid" +import { useI18n } from "../lib/i18n" +import { serverApi } from "../lib/api-client" +import { previewReads } from "../lib/background-read-queue" +import { loadMonaco } from "../lib/monaco/setup" +import type { GitImageDiff } from "../../../server/src/git-history-types" +import { GitImagePreview } from "./git-image-preview" +import { createDebouncedRefresh, filesystemInvalidationVersion } from "../lib/filesystem-events" +import type { FilePreviewTarget } from "../stores/files-preview" +import DiffToolbar from "./instance/shell/right-panel/components/DiffToolbar" +import type { DiffViewMode, DiffContextMode } from "./instance/shell/right-panel/types" +import { writeClientLayoutValue } from "../stores/client-state" +import { readStoredEnum, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY } from "./instance/shell/storage" + +const MonacoDiffViewer = lazy(() => import("./file-viewer/monaco-diff-viewer").then(module => ({ default: module.MonacoDiffViewer }))) + +export function GitDiffView(props: { instanceId: string; target: FilePreviewTarget; active: boolean; onClose: () => void; onInsertComment?: (text: string) => void }) { + const { t } = useI18n() + const [content, setContent] = createSignal<{ before: string; after: string; image?: GitImageDiff } | null>(null) + const [error, setError] = createSignal(null) + const [loading, setLoading] = createSignal(false) + const [view, setView] = createSignal(readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["unified", "split"] as const) ?? "unified") + const [context, setContext] = createSignal(readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["collapsed", "expanded"] as const) ?? "collapsed") + const [wrap, setWrap] = createSignal(readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) !== "off") + createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, view())) + createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, context())) + createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, wrap() ? "on" : "off")) + let controller: AbortController | undefined + let pending = false + + async function load() { + if (!props.active) return + if (loading()) { pending = true; return } + const request = controller = new AbortController(), target = props.target + void loadMonaco().catch(() => {}) + setLoading(true) + setError(null) + try { + const result = await previewReads.run<{ before: string; after: string; isBinary?: boolean; image?: GitImageDiff }>(request.signal, () => target.commit + ? serverApi.fetchGitCommitDiff(props.instanceId, target.slug, target.commit, target.path, request.signal) + : serverApi.fetchWorktreeGitDiff(props.instanceId, target.slug, { + path: target.path, originalPath: target.originalPath, scope: target.scope ?? "unstaged", + }, request.signal), "visible") + if (request.signal.aborted) return + if (result.isBinary && !result.image) { setContent(null); setError(t("instanceShell.gitChanges.binaryViewer")) } + else setContent(result) + } catch (cause) { + if (!request.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + if (controller === request) { + setLoading(false) + if (pending) { pending = false; void load() } + } + } + } + function cancel() { controller?.abort(); controller = undefined; pending = false; setLoading(false) } + createEffect(on(() => props.target, () => { cancel(); setContent(null); setError(null); void load() })) + createEffect(on(() => props.active, active => { if (!active) cancel(); else void load() }, { defer: true })) + const refresh = createDebouncedRefresh(() => void load()) + createEffect(on(() => filesystemInvalidationVersion(props.instanceId), () => { + if (props.active && !props.target.commit) refresh.trigger() + }, { defer: true })) + onCleanup(() => { cancel(); refresh.cancel() }) + + function insertContext(selection: { startLine: number; endLine: number }) { + if (!props.active) return + const revision = props.target.commit ? `Commit: ${props.target.commit} : ` : "" + const directory = props.target.serviceDirectory ?? props.target.directory + props.onInsertComment?.(`Git Diff: ${revision}Worktree: ${directory} : File: ${props.target.path} : ${selection.startLine}-${selection.endLine}`) + } + + return
+
+
+ {props.target.path} + {props.target.slug} · {props.target.commit + ? `${props.target.commit.slice(0, 8)} · ${props.target.subject ?? ""} · ${t("gitPanel.parentDiff")}` + : t(props.target.scope === "staged" ? "instanceShell.gitChanges.sections.staged" : "instanceShell.gitChanges.sections.unstaged")} +
+ + + +
+ +
{t("instanceInfo.loading")}
+ {value => + {t("instanceInfo.loading")}
}> + + +
}>{image => }} + +} diff --git a/packages/ui/src/components/git-image-preview.tsx b/packages/ui/src/components/git-image-preview.tsx new file mode 100644 index 000000000..be5416981 --- /dev/null +++ b/packages/ui/src/components/git-image-preview.tsx @@ -0,0 +1,19 @@ +import { For, Show, createEffect, createSignal, on } from "solid-js" +import type { GitImageDiff } from "../../../server/src/git-history-types" +import { useI18n } from "../lib/i18n" + +export function GitImagePreview(props: { image: GitImageDiff; path: string; view: "split" | "unified" }) { + const { t } = useI18n() + return
+ {side => { + const [failed, setFailed] = createSignal(false) + createEffect(on(() => props.image[side], () => setFailed(false))) + return {bytes =>
+
{t(`filesPanel.image.${side}`)}
+ {t("filesPanel.binary")}

}> + {`${props.path} setFailed(true)} /> +
+
}
+ }}
+
+} diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx index ba51aa4dd..6dac44771 100644 --- a/packages/ui/src/components/instance-service-status.tsx +++ b/packages/ui/src/components/instance-service-status.tsx @@ -127,7 +127,8 @@ const InstanceServiceStatus: Component = (props) => const switchDisabled = () => isPending() || !instance().client const statusDotClass = () => { if (isPending()) return "status-dot animate-pulse" - if (server.status === "running") return "status-dot ready animate-pulse" + // A connected server is idle; only pending operations need motion. + if (server.status === "running") return "status-dot ready" if (server.status === "error") return "status-dot error" return "status-dot stopped" } diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 56d5a832e..3fe7d71f9 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -49,7 +49,7 @@ import RightPanel from "./shell/right-panel/RightPanel" import { registerViewMenuPanels } from "../../lib/native/view-menu" import { useDrawerChrome } from "./shell/useDrawerChrome" import { getRetrySeconds, getSessionIdleFadeClass, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../../stores/session-status" -import { Command as CommandIcon, Globe, Maximize2, Search, ShieldAlert } from "lucide-solid" +import { Command as CommandIcon, Globe, ListFilter, Maximize2, Search, ShieldAlert } from "lucide-solid" import type { PromptInputApi } from "../prompt-input/types" import type { Attachment } from "../../types/attachment" import { setAgentModelPreference, useConfig } from "../../stores/preferences" @@ -88,7 +88,7 @@ import { runtimeEnv } from "../../lib/runtime-env" const log = getLogger("session") const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__" -const MIN_SESSION_CENTER_WIDTH = 480 +const MIN_SESSION_CENTER_WIDTH = 390 type SessionCenterWidthStep = "narrow" | "medium" | "wide" function getSessionCenterWidthStep(width: number): SessionCenterWidthStep { @@ -134,10 +134,13 @@ const InstanceShell2: Component = (props) => { const [sessionCenterEl, setSessionCenterEl] = createSignal(null) const [sessionCenterWidthStep, setSessionCenterWidthStep] = createSignal("wide") const [headerDensity, setHeaderDensity] = createSignal(0) + const [narrowHeaderActions, setNarrowHeaderActions] = createSignal(false) + const headerActionsCollapsed = () => narrowHeaderActions() || headerDensity() === 4 + const [filtersOpen, setFiltersOpen] = createSignal(false) let sessionToolbarEl: HTMLElement | undefined let headerLeftEl: HTMLElement | undefined let headerRightEl: HTMLElement | undefined - let headerIndicatorsEl: HTMLElement | undefined + const [headerIndicatorsEl, setHeaderIndicatorsEl] = createSignal() const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) let lastAutoOpenedFormId: string | null = null @@ -193,7 +196,6 @@ const InstanceShell2: Component = (props) => { const isPhoneLayout = createMemo(() => layoutMode() === "phone") const mobileFullscreen = createMemo(() => props.mobileFullscreenMode) - const compactPromptLayout = createMemo(() => layoutMode() !== "desktop") const { setDrawerHost, drawerContainer, drawerHostWidth, measureDrawerHost } = useDrawerHostMeasure() @@ -323,11 +325,15 @@ const InstanceShell2: Component = (props) => { createEffect(() => { if (!props.isActiveInstance || mobileFullscreen() || typeof ResizeObserver === "undefined") return + // Session/status changes can replace this subtree. Rebind the observer to + // the current node instead of continuing to observe a detached indicator. + const indicators = headerIndicatorsEl() + if (!indicators) return let frame = 0 const measure = () => { cancelAnimationFrame(frame) frame = requestAnimationFrame(() => { - if (!sessionToolbarEl || !headerLeftEl || !headerRightEl || !headerIndicatorsEl) return + if (!sessionToolbarEl || !headerLeftEl || !headerRightEl) return const gap = 8 let density = 4 @@ -335,7 +341,7 @@ const InstanceShell2: Component = (props) => { sessionCenterEl()?.setAttribute("data-session-header-density", String(candidate)) const leftRect = headerLeftEl.getBoundingClientRect() const rightRect = headerRightEl.getBoundingClientRect() - const indicatorsRect = headerIndicatorsEl.getBoundingClientRect() + const indicatorsRect = indicators.getBoundingClientRect() const sidesFit = leftRect.right + gap <= rightRect.left const indicatorsFit = indicatorsRect.width === 0 || (indicatorsRect.left >= leftRect.right + gap && indicatorsRect.right <= rightRect.left - gap) @@ -348,7 +354,7 @@ const InstanceShell2: Component = (props) => { }) } const observer = new ResizeObserver(measure) - ;[sessionToolbarEl, headerLeftEl, headerRightEl, headerIndicatorsEl] + ;[sessionToolbarEl, headerLeftEl, headerRightEl, indicators] .forEach((element) => element && observer.observe(element)) measureDrawerHost() measure() @@ -377,9 +383,18 @@ const InstanceShell2: Component = (props) => { const element = sessionCenterEl() if (!element || typeof ResizeObserver === "undefined") return + let focusFrame = 0 const updateWidthStep = (width: number) => { if (width <= 0) return + const moveFocus = width < 420 && sessionToolbarEl?.querySelector(".session-header-expanded-actions")?.contains(document.activeElement) setSessionCenterWidthStep(getSessionCenterWidthStep(width)) + setNarrowHeaderActions(width < 420) + if (moveFocus) { + cancelAnimationFrame(focusFrame) + focusFrame = requestAnimationFrame(() => { + if (headerActionsCollapsed()) sessionToolbarEl?.querySelector(".session-header-actions-menu")?.focus() + }) + } } measureDrawerHost() @@ -391,7 +406,10 @@ const InstanceShell2: Component = (props) => { }) observer.observe(element) - onCleanup(() => observer.disconnect()) + onCleanup(() => { + observer.disconnect() + cancelAnimationFrame(focusFrame) + }) }) const connectionStatus = () => sseManager.getStatus(props.instance.id) @@ -575,7 +593,7 @@ const InstanceShell2: Component = (props) => { } const renderSessionHeaderIndicators = () => ( -
{ headerIndicatorsEl = element }} class="session-header-indicators flex items-center justify-center gap-2"> +
= (props) => { const searchWindowId = () => sessionSearchWindowId(props.instance.id, activeSessionIdForInstance() ?? "") const paletteWindowId = () => `command-palette-${props.instance.id}` + createEffect(() => { + activeSessionIdForInstance() + props.isActiveInstance + setFiltersOpen(false) + }) + const headerActionMenuItems = (): ActionOverflowMenuItem[] => { const items: ActionOverflowMenuItem[] = [{ key: "commands", @@ -652,6 +676,12 @@ const InstanceShell2: Component = (props) => { :
= (props) => { {t("instanceShell.connection.disconnected")} - -
-
@@ -1201,7 +1233,6 @@ const InstanceShell2: Component = (props) => { instanceFolder={props.instance.folder} sessionId={NO_SESSION_DRAFT_SESSION_ID} isActive={props.isActiveInstance} - compactLayout={compactPromptLayout()} onSend={handleFirstPromptSend} onCommand={handleFirstPromptCommand} onRunShell={handleFirstPromptShell} @@ -1242,7 +1273,6 @@ const InstanceShell2: Component = (props) => { instanceFolder={props.instance.folder} escapeInDebounce={props.escapeInDebounce} isPhoneLayout={isPhoneLayout()} - compactPromptLayout={compactPromptLayout()} focusConversationOnActivate={focusConversationSessionId() === sessionId} onConversationFocusHandled={() => { if (focusConversationSessionId() === sessionId) setFocusConversationSessionId(null) diff --git a/packages/ui/src/components/instance/shell/drawer-layout.test.ts b/packages/ui/src/components/instance/shell/drawer-layout.test.ts index c713ae604..8f13809e1 100644 --- a/packages/ui/src/components/instance/shell/drawer-layout.test.ts +++ b/packages/ui/src/components/instance/shell/drawer-layout.test.ts @@ -4,7 +4,7 @@ import test from "node:test" import { clampEmbeddedDrawerWidth, resolveEmbeddedDrawers } from "./drawer-layout.ts" const widths = { - minimumCenterWidth: 480, + minimumCenterWidth: 390, minimumLeftWidth: 220, minimumRightWidth: 200, leftWidth: 320, @@ -20,31 +20,31 @@ test("drawers shrink before progressively overlaying", () => { leftWidth: 320, rightWidth: 400, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 1_000 }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 910 }), { left: true, right: true, leftWidth: 320, rightWidth: 200, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 900 }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 810 }), { left: true, right: true, leftWidth: 220, rightWidth: 200, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 700 }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 610 }), { left: true, right: false, leftWidth: 220, rightWidth: 400, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 699 }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 609 }), { left: false, right: false, leftWidth: 320, rightWidth: 400, }) - assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 900, leftOpen: false }), { + assert.deepEqual(resolveEmbeddedDrawers({ ...widths, hostWidth: 810, leftOpen: false }), { left: false, right: true, leftWidth: 320, diff --git a/packages/ui/src/components/instance/shell/right-panel/FileRowActions.tsx b/packages/ui/src/components/instance/shell/right-panel/FileRowActions.tsx new file mode 100644 index 000000000..d81177528 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/FileRowActions.tsx @@ -0,0 +1,28 @@ +import type { Component } from "solid-js" +import { For, onMount } from "solid-js" +import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../../../action-overflow-menu" +import { observeRowOverflow } from "./row-compact-actions" + +// File row actions with the message/session convention: inline icon buttons +// while they fit, the shared overflow menu only when they don't. Hidden +// actions stay measurable but inert; an open menu stays mounted. +const FileRowActions: Component<{ items: ActionOverflowMenuItem[]; label: string }> = props => { + let element!: HTMLSpanElement + onMount(() => observeRowOverflow(element)) + // Preview toggles remain directly reachable; only secondary actions overflow. + const toggles = () => props.items.filter(item => typeof item.checked === "boolean") + const secondary = () => props.items.filter(item => typeof item.checked !== "boolean") + const button = (item: ActionOverflowMenuItem) => + return event.stopPropagation()} onPointerDown={event => event.stopPropagation()}> + {button} + + {button} + + + +} + +export default FileRowActions diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 212bf195d..5b9f1f121 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -30,6 +30,7 @@ import { type RightPanelTabModule, } from "./registry" import { createCoreRightPanelRuntime } from "./core-runtime" +import { FILES_PANEL_MIGRATION_KEY, mergeFilesPanelCustomization } from "./files-panel-state" import { loadRightPanelPluginManifests, type RightPanelPluginLoadError } from "./plugin-manifest" import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" @@ -93,15 +94,20 @@ interface RightPanelProps { } const RightPanel: Component = (props) => { - const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("git-changes")) + const savedTab = readStoredRightPanelTab("files") + const [rightPanelTab, setRightPanelTab] = createSignal(savedTab === "git-changes" ? "files" : savedTab) const defaultStatusSectionIds = CORE_STATUS_SECTION_ITEMS.map((section) => section.id) const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal(defaultStatusSectionIds) const [rightPanelCustomizationOpen, setRightPanelCustomizationOpen] = createSignal(false) let customizationTriggerRef: HTMLButtonElement | undefined let customizationPopoverRef: HTMLDivElement | undefined - const [rightPanelCustomization, setRightPanelCustomization] = createSignal( - parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)), - ) + let savedCustomization = parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)) + if (!readClientLayoutValue(FILES_PANEL_MIGRATION_KEY)) { + savedCustomization = mergeFilesPanelCustomization(savedCustomization) + writeClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY, JSON.stringify(savedCustomization)) + writeClientLayoutValue(FILES_PANEL_MIGRATION_KEY, "1") + } + const [rightPanelCustomization, setRightPanelCustomization] = createSignal(savedCustomization) const tabGroupId = `right-panel-${createUniqueId()}` const tabId = (id: string) => `${tabGroupId}-tab-${id}` const tabPanelId = (id: string) => `${tabGroupId}-panel-${id}` diff --git a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx index 6c2017c4a..c8a104d54 100644 --- a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx @@ -1,6 +1,6 @@ import type { Component } from "solid-js" -import { AlignJustify, FoldVertical, Split, UnfoldVertical } from "lucide-solid" +import { Columns, FoldVertical, Rows, UnfoldVertical } from "lucide-solid" import { useI18n } from "../../../../../lib/i18n" import type { DiffContextMode, DiffViewMode } from "../types" @@ -31,7 +31,7 @@ const DiffToolbar: Component = (props) => { data-active={props.viewMode === "split" ? "true" : undefined} title={viewModeTitle()} > - {nextViewMode() === "split" ?
-
- -
- -
-
-
{props.list.panel()}
-
- - - - - - {props.list.overlay()} - - -
-
- ) -} - -export default SplitFilePanel diff --git a/packages/ui/src/components/instance/shell/right-panel/components/WorkspaceTree.tsx b/packages/ui/src/components/instance/shell/right-panel/components/WorkspaceTree.tsx new file mode 100644 index 000000000..33d19b785 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/components/WorkspaceTree.tsx @@ -0,0 +1,176 @@ +import { For, Show, createMemo } from "solid-js" +import { ChevronDown, ChevronRight, Copy, ExternalLink, Eye, Folder, FolderOpen, FileCode, FileText, Image, File, RefreshCw, TerminalSquare } from "lucide-solid" +import type { useWorkspaceTree } from "../useWorkspaceTree" +import type { ActionOverflowMenuItem } from "../../../../action-overflow-menu" +import FileRowActions from "../FileRowActions" +import { copyToClipboard } from "../../../../../lib/clipboard" +import { showToastNotification } from "../../../../../lib/notifications" +import { canOpenWorkspacePaths, openWorkspacePath } from "../../../../../lib/workspace-open" + +export function WorkspaceTree(props: { + tree: ReturnType + t: (key: string, vars?: Record) => string + directory: string + instanceId: string + worktreeSlug: string + canOpen: boolean + previewPath?: string + onOpen: (path: string) => void +}) { + const icon = (name: string) => /\.(png|jpe?g|gif|webp|svg|ico|bmp|avif)$/i.test(name) ? Image + : /\.(md|mdx|txt|rst)$/i.test(name) ? FileText + : /\.(tsx?|jsx?|json|ya?ml|css|html|py|rs|sh)$/i.test(name) ? FileCode : File + let root!: HTMLDivElement + const tabStop = createMemo(() => props.tree.rows().some(row => row.path === props.tree.state().selected) + ? props.tree.state().selected : props.tree.rows()[0]?.path) + const focusRow = (path: string) => root.querySelector(`[data-path="${CSS.escape(path)}"]`)?.focus() + const activateRow = (row: ReturnType[number]) => { + props.tree.select(row.path) + if (row.type === "directory") props.tree.toggle(row.path) + } + function keyDown(event: KeyboardEvent, row: ReturnType[number]) { + // The row menu trigger is a separate tab stop; its keys must not toggle the row. + if (event.target !== event.currentTarget) return + const rows = props.tree.rows(), index = rows.findIndex(entry => entry.path === row.path) + const rtl = document.documentElement.dir === "rtl" + if (event.key === "ArrowDown") focusRow(rows[Math.min(index + 1, rows.length - 1)]!.path) + else if (event.key === "ArrowUp") focusRow(rows[Math.max(index - 1, 0)]!.path) + else if (event.key === "Home") focusRow(rows[0]!.path) + else if (event.key === "End") focusRow(rows[rows.length - 1]!.path) + else if (event.key === (rtl ? "ArrowLeft" : "ArrowRight") && row.type === "directory") { + if (!props.tree.state().expanded.has(row.path)) props.tree.toggle(row.path) + else if (rows[index + 1]?.parent === row.path) focusRow(rows[index + 1]!.path) + } else if (event.key === (rtl ? "ArrowRight" : "ArrowLeft")) { + if (row.type === "directory" && props.tree.state().expanded.has(row.path)) props.tree.toggle(row.path) + else focusRow(row.parent) + } else if (event.key === "Enter" || event.key === " ") { + activateRow(row) + } else return + event.preventDefault() + } + + const handleCopyPath = async (path: string) => { + const ok = await copyToClipboard(path) + showToastNotification({ + message: ok ? props.t("instanceShell.filesShell.toast.copyPathSuccess") : props.t("instanceShell.filesShell.toast.copyPathError"), + variant: ok ? "success" : "error", + }) + } + + const handleNativeOpen = async (target: "default" | "reveal" | "terminal", path: string) => { + try { + await openWorkspacePath({ target, instanceId: props.instanceId, worktreeSlug: props.worktreeSlug, path }) + } catch (error) { + showToastNotification({ + message: props.t("instanceShell.filesShell.toast.openError", { + message: error instanceof Error ? error.message : String(error), + }), + variant: "error", + }) + } + } + + const rowActions = (row: ReturnType[number]): ActionOverflowMenuItem[] => { + const items: ActionOverflowMenuItem[] = [] + if (row.type === "file") items.push({ + key: "preview", label: `${props.t("filesPanel.viewer")} · ${row.path}`, + icon: , disabled: !props.canOpen, + checked: props.previewPath === row.path, onSelect: () => props.onOpen(row.path), + }) + if (canOpenWorkspacePaths()) { + if (row.type === "directory") { + const isMacApp = isMacDesktop && row.path.toLowerCase().endsWith(".app") + items.push( + { + key: "open-folder", + label: props.t(isMacApp + ? "instanceShell.filesShell.actions.showInFolder" + : "instanceShell.filesShell.actions.openFolder"), + icon: , + onSelect: () => handleNativeOpen(isMacApp ? "reveal" : "default", row.path), + }, + { + key: "open-terminal", + label: props.t("instanceShell.filesShell.actions.openTerminal"), + icon: , + onSelect: () => handleNativeOpen("terminal", row.path), + }, + ) + } else { + if (isWindowsDesktop) { + items.push({ + key: "open-default", + label: props.t({ + open: "instanceShell.filesShell.actions.openDefault", + edit: "instanceShell.filesShell.actions.editDefault", + choose: "instanceShell.filesShell.actions.chooseApplication", + }[windowsDefaultAction(row.path)]), + icon: , + onSelect: () => handleNativeOpen("default", row.path), + }) + } + items.push({ + key: "show-in-folder", + label: props.t("instanceShell.filesShell.actions.showInFolder"), + icon: , + onSelect: () => handleNativeOpen("reveal", row.path), + }) + } + } + items.push({ + key: "copy-path", + label: props.t("instanceShell.filesShell.actions.copyPath"), + icon: , + onSelect: () => handleCopyPath(row.path), + }) + return items + } + + return
+
{props.directory.replace(/\\/g, "/").split("/").pop()}
+
+ {row => { + const isFolder = row.type === "directory", Icon = icon(row.name) + const expanded = () => props.tree.state().expanded.has(row.path) + return
props.tree.select(row.path)} onKeyDown={event => keyDown(event, row)} + onClick={() => activateRow(row)}> + }> + }> + + }>}> + {row.name} + +
+ }}
+
+ {([path, error]) => } +

{props.t("instanceInfo.loading")}

+
+} + +const isWindowsDesktop = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent) +const isMacDesktop = typeof navigator !== "undefined" && /macintosh|mac os x/i.test(navigator.userAgent) +const safeWindowsOpenExtensions = new Set([ + "7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff", "docx", + "env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini", "java", "jpeg", "jpg", + "json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua", "md", "markdown", "mkv", "mov", "mp3", + "mp4", "ogg", "patch", "pdf", "png", "pptx", "r", "rar", "rmd", "rs", "scss", "sql", "svg", "svelte", + "swift", "tar", "toml", "ts", "tsx", "txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip", +]) +const windowsEditExtensions = new Set([ + "bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe", "vbs", "wsf", "wsh", +]) + +function windowsDefaultAction(path: string): "open" | "edit" | "choose" { + const name = path.split(/[\\/]/).pop() ?? "" + const dot = name.lastIndexOf(".") + const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : "" + if (!isWindowsDesktop || (extension && safeWindowsOpenExtensions.has(extension))) return "open" + if (extension && windowsEditExtensions.has(extension)) return "edit" + return "choose" +} diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx index a5ce72347..783f8b6a6 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -5,7 +5,6 @@ import type { RightPanelModule } from "./registry" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" interface CoreRightPanelRenderers { - renderGitChangesTab: () => JSX.Element renderFilesTab: () => JSX.Element renderStatusTab: () => JSX.Element } @@ -31,12 +30,6 @@ export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers) descriptionKey: "instanceShell.rightPanel.modules.core.description", origin: "first-party", tabs: [ - { - id: "git-changes", - labelKey: "instanceShell.rightPanel.tabs.gitChanges", - order: 10, - render: renderers.renderGitChangesTab, - }, { id: "files", labelKey: "instanceShell.rightPanel.tabs.files", diff --git a/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx index bd8ec9654..ef80c0943 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx @@ -1,38 +1,21 @@ -import { createEffect, createMemo, createSignal, lazy, type Accessor } from "solid-js" - +import { createEffect, createMemo, createSignal, lazy, on, onCleanup, type Accessor } from "solid-js" import type { Instance } from "../../../../types/instance" import type { Session } from "../../../../types/session" import type { PromptInputApi } from "../../../prompt-input/types" -import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" +import type { RightPanelTab } from "./types" import type { RightPanelCustomization, RightPanelSectionModule } from "./registry" - -import { - getDefaultWorktreeSlug, - getGitRepoStatus, - getWorktreeSlugForSession, - getWorktrees, -} from "../../../../stores/worktrees" -import { writeClientLayoutValue } from "../../../../stores/client-state" -import { - RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, - RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, - RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, - RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, - RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY, - readStoredBool, - readStoredEnum, -} from "../storage" +import { getDefaultWorktreeSlug, getWorktreeSlugForSession, getWorktrees, getGitRepoStatus } from "../../../../stores/worktrees" +import { closeFilePreview, openFilePreview, type FilePreviewTarget } from "../../../../stores/files-preview" +import { showSessionChat } from "../../../../stores/session-previews" import { useGitChanges } from "./useGitChanges" +import { useGitHistory } from "./useGitHistory" import { createCoreRightPanelManifest } from "./core-plugin" -import { createFilesTabRuntime } from "./tabs/files-runtime" -import { createSplitResize } from "./tabs/split-resize" +import { useWorkspaceTree } from "./useWorkspaceTree" +import { FILES_PANEL_MODE_KEY, type FilesPanelMode } from "./files-panel-state" +import { readStoredEnum } from "../storage" +import { writeClientLayoutValue } from "../../../../stores/client-state" -const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) +const LazyFilesPanel = lazy(() => import("./tabs/FilesPanel")) const LazyStatusTab = lazy(() => import("./tabs/StatusTab")) interface CoreRightPanelRuntimeOptions { @@ -55,181 +38,54 @@ interface CoreRightPanelRuntimeOptions { } export function createCoreRightPanelRuntime(options: CoreRightPanelRuntimeOptions) { - const [diffViewMode, setDiffViewMode] = createSignal( - readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified", - ) - const [diffContextMode, setDiffContextMode] = createSignal( - readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", - ) - const [diffWordWrapMode, setDiffWordWrapMode] = createSignal( - readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on", - ) - const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) - const [gitStagedOpen, setGitStagedOpen] = createSignal(true) - const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true) - - const listLayoutKey = createMemo(() => (options.isPhoneLayout() ? "phone" : "nonphone")) - - const gitListOpenStorageKey = createMemo(() => - listLayoutKey() === "phone" ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, - ) - - const gitSectionStorageKey = (section: "staged" | "unstaged") => { - const phone = listLayoutKey() === "phone" - if (section === "staged") { - return phone ? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY - } - return phone ? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY - } - - createEffect(() => { - gitListOpenStorageKey() - const gitPersisted = readStoredBool(gitListOpenStorageKey()) - if (gitPersisted !== null) { - setGitChangesListOpen(gitPersisted) - } else { - setGitChangesListOpen(true) - } - - setGitStagedOpen(readStoredBool(gitSectionStorageKey("staged")) ?? true) - setGitUnstagedOpen(readStoredBool(gitSectionStorageKey("unstaged")) ?? true) - }) - - createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())) - createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())) - createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())) - - const gitChangesSplit = createSplitResize({ - storageKey: RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, - defaultWidth: 320, - rightDrawerWidth: options.rightDrawerWidth, - rightDrawerWidthInitialized: options.rightDrawerWidthInitialized, - }) - - const worktreeSlugForViewer = createMemo(() => { + const [mode, setMode] = createSignal(readStoredEnum(FILES_PANEL_MODE_KEY, ["workspace", "changes", "history"] as const) ?? "workspace") + createEffect(() => writeClientLayoutValue(FILES_PANEL_MODE_KEY, mode())) + const [browsedWorktree, setBrowsedWorktree] = createSignal(null) + const sessionWorktree = createMemo(() => { const sessionId = options.activeSessionId() - if (sessionId && sessionId !== "info") { - return getWorktreeSlugForSession(options.instanceId, sessionId) + return sessionId && sessionId !== "info" ? getWorktreeSlugForSession(options.instanceId, sessionId) : getDefaultWorktreeSlug(options.instanceId) + }) + createEffect(on(() => `${options.activeSessionId()}:${sessionWorktree()}`, () => { + setBrowsedWorktree(null) + closeFilePreview(options.instanceId) + })) + const slug = createMemo(() => browsedWorktree() ?? sessionWorktree()) + const worktrees = createMemo(() => getWorktrees(options.instanceId)) + const worktree = createMemo(() => worktrees().find(entry => entry.slug === slug())) + createEffect(() => { + // A removed browsed worktree must never silently turn into a root-file read. + if (browsedWorktree() && worktrees().length && !worktree()) { + setBrowsedWorktree(null) + closeFilePreview(options.instanceId) } - return getDefaultWorktreeSlug(options.instanceId) }) - - const gitChangesWorktreeSlug = createMemo(() => { - if (getGitRepoStatus(options.instanceId) === false) return null - const slug = worktreeSlugForViewer().trim() - return slug ? slug : null - }) - - const gitChangesWorktree = createMemo(() => { - const slug = gitChangesWorktreeSlug() - if (!slug) return null - return getWorktrees(options.instanceId).find((worktree) => worktree.slug === slug) ?? null - }) - - const gitChangesBranchLabel = createMemo(() => gitChangesWorktree()?.branch?.trim() || null) - const gitScopeKey = createMemo(() => `${options.instanceId}:git:${worktreeSlugForViewer()}`) + const active = () => options.isActive() && options.rightPanelTab() === "files" + const gitAvailable = () => getGitRepoStatus(options.instanceId) !== false + createEffect(() => { if (!gitAvailable()) setMode("workspace") }) + const directory = () => worktree()?.directory ?? options.instance.folder + const tree = useWorkspaceTree(options.instanceId, directory, () => active() && mode() === "workspace") + const history = useGitHistory(options.instanceId, slug, () => active() && gitAvailable() && mode() === "history") const git = useGitChanges({ - isActive: options.isActive, - t: options.t, - instanceId: options.instanceId, - rightPanelTab: options.rightPanelTab, - worktreeSlug: worktreeSlugForViewer, - isPhoneLayout: options.isPhoneLayout, - promptInputApi: options.promptInputApi, - closeGitList: () => setGitChangesListOpen(false), + ...options, isActive: () => active() && gitAvailable() && mode() === "changes", rightPanelTab: () => "git-changes", + worktreeSlug: slug, closeGitList: () => {}, externalDiff: true, }) - const renderFilesTab = createFilesTabRuntime({ - t: options.t, - instanceId: options.instanceId, - rightPanelTab: options.rightPanelTab, - worktreeSlug: worktreeSlugForViewer, - isPhoneLayout: options.isPhoneLayout, - rightDrawerWidth: options.rightDrawerWidth, - rightDrawerWidthInitialized: options.rightDrawerWidthInitialized, - }) - - const persistGitListOpen = (value: boolean) => { - writeClientLayoutValue(gitListOpenStorageKey(), value ? "true" : "false") + const openFile = (file: Pick) => { + const sessionId = options.activeSessionId() + if (!sessionId || sessionId === "info") return + showSessionChat(options.instance.folder) + openFilePreview(options.instanceId, { ...file, sessionId, slug: slug(), directory: directory(), serviceDirectory: worktree()?.serviceDirectory }) } - - const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => { - writeClientLayoutValue(gitSectionStorageKey(section), value ? "true" : "false") - } - - const toggleGitList = () => { - setGitChangesListOpen((current) => { - const next = !current - persistGitListOpen(next) - return next - }) - } - + onCleanup(() => closeFilePreview(options.instanceId)) return createCoreRightPanelManifest({ - renderGitChangesTab: () => ( - void git.refreshGitStatus()} - onInsertContext={git.insertGitChangeContext} - onStageFile={git.stageGitFile} - onUnstageFile={git.unstageGitFile} - commitMessage={git.gitCommitMessage} - commitSubmitting={git.gitCommitSubmitting} - onCommitMessageInput={git.setGitCommitMessage} - onSubmitCommit={() => void git.submitGitCommit()} - branchLabel={gitChangesBranchLabel} - stagedOpen={gitStagedOpen} - unstagedOpen={gitUnstagedOpen} - onToggleStagedOpen={() => { - const next = !gitStagedOpen() - setGitStagedOpen(next) - persistGitSectionOpen("staged", next) - }} - onToggleUnstagedOpen={() => { - const next = !gitUnstagedOpen() - setGitUnstagedOpen(next) - persistGitSectionOpen("unstaged", next) - }} - listOpen={gitChangesListOpen} - onToggleList={toggleGitList} - splitWidth={gitChangesSplit.splitWidth} - onResizeMouseDown={gitChangesSplit.onResizeMouseDown} - onResizeTouchStart={gitChangesSplit.onResizeTouchStart} - isPhoneLayout={options.isPhoneLayout} - /> - ), - renderFilesTab, - renderStatusTab: () => ( - options.isActive() && options.rightPanelTab() === "status"} - expandedItems={options.expandedItems} - onExpandedItemsChange={options.onExpandedItemsChange} - customization={options.customization} - onCustomizationChange={options.onCustomizationChange} - extraSections={options.extraStatusSections()} - /> - ), + renderFilesTab: () => { + setBrowsedWorktree(value) + closeFilePreview(options.instanceId) + }} onOpenFile={openFile} canOpenFile={Boolean(options.activeSessionId() && options.activeSessionId() !== "info")} />, + renderStatusTab: () => options.isActive() && options.rightPanelTab() === "status"} + expandedItems={options.expandedItems} onExpandedItemsChange={options.onExpandedItemsChange} + customization={options.customization} onCustomizationChange={options.onCustomizationChange} extraSections={options.extraStatusSections()} />, }) } diff --git a/packages/ui/src/components/instance/shell/right-panel/files-panel-state.test.ts b/packages/ui/src/components/instance/shell/right-panel/files-panel-state.test.ts new file mode 100644 index 000000000..ebc478002 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/files-panel-state.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { mergeFilesPanelCustomization } from "./files-panel-state" + +test("merging Git and Files preserves order and keeps either visible entry accessible", () => { + const state = { tabOrder: ["status", "git-changes", "files"], hiddenTabIds: ["files"], statusSectionOrder: ["tokens"], hiddenStatusSectionIds: [] } + assert.deepEqual(mergeFilesPanelCustomization(state), { ...state, tabOrder: ["status", "files"], hiddenTabIds: [] }) + assert.deepEqual(mergeFilesPanelCustomization({ ...state, hiddenTabIds: ["files", "git-changes"] }).hiddenTabIds, ["files"]) + assert.deepEqual(mergeFilesPanelCustomization({ ...state, tabOrder: ["files", "status", "git-changes"] }).tabOrder, ["files", "status"]) +}) diff --git a/packages/ui/src/components/instance/shell/right-panel/files-panel-state.ts b/packages/ui/src/components/instance/shell/right-panel/files-panel-state.ts new file mode 100644 index 000000000..3b54a3d36 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/files-panel-state.ts @@ -0,0 +1,14 @@ +import type { RightPanelCustomization } from "./registry" + +export const FILES_PANEL_MIGRATION_KEY = "opencode-files-panel-migration-v1" +export const FILES_PANEL_MODE_KEY = "opencode-files-panel-mode-v1" +export type FilesPanelMode = "workspace" | "changes" | "history" + +// Called once per client layout. Either previously visible entry keeps the +// merged Files surface available; retain the earliest position in the tab strip. +export function mergeFilesPanelCustomization(value: RightPanelCustomization): RightPanelCustomization { + const hidden = value.hiddenTabIds.filter(id => id !== "git-changes" && id !== "files") + if (value.hiddenTabIds.includes("files") && value.hiddenTabIds.includes("git-changes")) hidden.push("files") + return { ...value, hiddenTabIds: hidden, + tabOrder: [...new Set(value.tabOrder.map(id => id === "git-changes" ? "files" : id))] } +} diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts index 2820d504a..0fffe4822 100644 --- a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -62,7 +62,6 @@ describe("right panel plugin manifests", () => { it("defines core right panel tabs and status sections as manifests", () => { const render = () => undefined as any const rightPanel = createCoreRightPanelManifest({ - renderGitChangesTab: render, renderFilesTab: render, renderStatusTab: render, }) @@ -77,7 +76,7 @@ describe("right panel plugin manifests", () => { const rightPanelModule = rightPanel.create(host) - assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["git-changes", "files", "status"]) + assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["files", "status"]) assert.equal(rightPanelModule.tabs?.find((entry) => entry.id === "status")?.alwaysVisible, true) assert.deepEqual(statusSections.statusSections?.map((entry) => entry.id), [ "tokens", diff --git a/packages/ui/src/components/instance/shell/right-panel/row-compact-actions.ts b/packages/ui/src/components/instance/shell/right-panel/row-compact-actions.ts new file mode 100644 index 000000000..8efe189af --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/row-compact-actions.ts @@ -0,0 +1,96 @@ +import { onCleanup } from "solid-js" + +const px = (value: string) => Number.parseFloat(value) || 0 + +type Measurement = () => (() => void) | undefined +const pendingMeasurements = new Set() +let scheduledFrame = 0 +function scheduleMeasurement(measure: Measurement) { + pendingMeasurements.add(measure) + if (scheduledFrame) return + scheduledFrame = requestAnimationFrame(() => { + scheduledFrame = 0 + const pending = [...pendingMeasurements] + pendingMeasurements.clear() + // Read every row before changing any DOM. Per-row read/write callbacks + // repeatedly laid out the entire inventory when thousands of files mounted. + const updates = pending.map(read => read()) + for (const update of updates) update?.() + }) +} + +// Width of the row content ignoring the overflow trigger, so hidden inline +// actions stay measurable. Text nodes are measured with a Range because the +// label truncates with ellipsis. +function rowContentWidth(element: Element): number { + if (element.matches(".action-overflow-trigger")) return 0 + const style = getComputedStyle(element) + if (style.display === "none") return 0 + if (element instanceof SVGElement || element.matches("button:not(.git-panel-file-main), .workspace-tree-spacer")) { + return element.getBoundingClientRect().width + } + const widths: number[] = [] + for (const child of element.childNodes) { + if (child instanceof Element) widths.push(rowContentWidth(child)) + else if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) { + const range = document.createRange() + range.selectNode(child) + widths.push(range.getBoundingClientRect().width) + } + } + const visible = widths.filter((width) => width > 0) + const stacked = style.flexDirection === "column" + return (stacked ? Math.max(0, ...visible) : visible.reduce((a, b) => a + b, 0)) + + (stacked ? 0 : Math.max(0, visible.length - 1) * px(style.columnGap)) + + px(style.paddingLeft) + px(style.paddingRight) + px(style.borderLeftWidth) + px(style.borderRightWidth) +} + +// Toggle data-compact on a file row: inline icon buttons while they fit, the +// shared overflow menu only when they don't. An open menu stays mounted +// across resizes; hidden actions stay measurable but inert. +export function observeRowOverflow(element: HTMLElement) { + const row = element.parentElement + if (!row) return + let disposed = false + const measure = () => { + if (disposed || !element.isConnected || !row.getBoundingClientRect().width) return + const menu = element.querySelector(".action-overflow-trigger") + const inline = element.querySelector(".file-row-inline-actions") + if (!menu || !inline) return + // Budget against the entire row, not the shrink-wrapped action group. + // The menu replaces inline actions; it must not add to their width budget. + const required = rowContentWidth(row) + const available = row.getBoundingClientRect().width + const next = menu.hasAttribute("data-expanded") || required > available + 0.5 + if (element.dataset.compact === String(next)) return + const active = document.activeElement + const hadActionFocus = active instanceof Element && element.contains(active) + && active.matches(".file-row-inline-actions button, .action-overflow-trigger") + return () => { + if (disposed || !element.isConnected) return + element.dataset.compact = String(next) + inline.inert = next + if (hadActionFocus) queueMicrotask(() => { + if (disposed || !element.isConnected) return + const target = next ? menu : element.querySelector(".file-row-inline-actions button:not(:disabled)") + target?.focus({ preventScroll: true }) + }) + } + } + const schedule = () => { if (!disposed) scheduleMeasurement(measure) } + const resize = new ResizeObserver(schedule) + resize.observe(row) + resize.observe(element) + const mutation = new MutationObserver(schedule) + mutation.observe(row, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["data-expanded"] }) + document.fonts?.addEventListener("loadingdone", schedule) + schedule() + onCleanup(() => { + disposed = true + pendingMeasurements.delete(measure) + if (!pendingMeasurements.size) { cancelAnimationFrame(scheduledFrame); scheduledFrame = 0 } + resize.disconnect() + mutation.disconnect() + document.fonts?.removeEventListener("loadingdone", schedule) + }) +} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesPanel.tsx new file mode 100644 index 000000000..51baf8cbd --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesPanel.tsx @@ -0,0 +1,240 @@ +import { For, Show, createMemo, createSignal, createUniqueId, type Component, type JSX } from "solid-js" +import { + DragDropProvider, + DragDropSensors, + closestCenter, + createDraggable, + createDroppable, + transformStyle, + type DragEvent as SolidDndDragEvent, +} from "@thisbeyond/solid-dnd" +import { ArrowLeft, ChevronRight, Eye, GitBranch, GitCommitHorizontal, Minus, Plus, RefreshCw } from "lucide-solid" +import type { useGitChanges } from "../useGitChanges" +import type { useGitHistory } from "../useGitHistory" +import { closeFilePreview, getFilePreview, type FilePreviewTarget } from "../../../../../stores/files-preview" +import FileRowActions from "../FileRowActions" +import type { ActionOverflowMenuItem } from "../../../../action-overflow-menu" +import { buildGitChangeListItems } from "../git-changes-model" +import type { GitChangeListItem, GitChangeSection } from "../types" +import type { useWorkspaceTree } from "../useWorkspaceTree" +import { WorkspaceTree } from "../components/WorkspaceTree" +import type { FilesPanelMode } from "../files-panel-state" + +interface FilesPanelProps { + t: (key: string, vars?: Record) => string + git: ReturnType + history: ReturnType + tree: ReturnType + gitAvailable: boolean + mode: FilesPanelMode + onModeChange: (mode: FilesPanelMode) => void + worktrees: Array<{ slug: string; directory: string; branch?: string | null }> + slug: string + directory: string + instanceId: string + branch: string | null + onWorktreeChange: (slug: string) => void + onOpenFile: (file: Pick) => void + canOpenFile: boolean +} + +const FilesPanel: Component = props => { + const items = createMemo(() => buildGitChangeListItems(props.git.gitStatusEntries())) + const [filter, setFilter] = createSignal("") + const [selectedCommitFile, setSelectedCommitFile] = createSignal("") + const previewActive = (file: Parameters[0]) => { + const target = getFilePreview(props.instanceId) + return Boolean(target && target.slug === props.slug && target.directory === props.directory && target.path === file.path + && (target.kind ?? "diff") === (file.kind ?? "diff") && target.commit === file.commit && target.scope === file.scope) + } + const togglePreview: FilesPanelProps["onOpenFile"] = file => { + if (previewActive(file)) closeFilePreview(props.instanceId) + else props.onOpenFile(file) + } + let historyBody: HTMLDivElement | undefined + let historyScroll = 0 + const selectCommit = (id: string) => { + historyScroll = historyBody?.scrollTop ?? 0 + void props.history.select(id) + } + const backToCommits = () => { + props.history.back() + requestAnimationFrame(() => { if (historyBody?.isConnected) historyBody.scrollTop = historyScroll }) + } + const commits = createMemo(() => (props.history.page()?.commits ?? []).filter(commit => + `${commit.subject} ${commit.author} ${commit.id}`.toLowerCase().includes(filter().toLowerCase()))) + const handleStageDragEnd = ({ draggable, droppable }: SolidDndDragEvent) => { + if (!droppable) return + const target = String(droppable.id) + if (target !== "staged" && target !== "unstaged") return + const item = items().find(entry => entry.id === String(draggable.id)) + if (!item || item.section === target) return + if (target === "staged") props.git.stageGitFile(item) + else props.git.unstageGitFile(item) + } + const loading = () => props.mode === "workspace" ? props.tree.busy().size > 0 : props.mode === "history" ? props.history.loading() || props.history.detailLoading() : props.git.gitStatusLoading() + const error = () => props.mode === "workspace" ? null : props.mode === "history" ? props.history.error() : props.git.gitStatusError() + const basename = (path: string) => path.replace(/\\/g, "/").split("/").pop() || path + const date = (value: string) => new Date(value).toLocaleDateString(document.documentElement.lang || undefined, { month: "short", day: "numeric" }) + + return
+
+
{props.gitAvailable ? props.branch || props.t("gitPanel.detached") : basename(props.directory)} + +
+ +
+
+
+ + + +
+ + {props.t("gitPanel.files", { count: props.git.gitStatusEntries()?.length ?? 0 })} + +
+ +

{props.t("instanceShell.gitChanges.noSessionSelected")}

+
+ togglePreview({ kind: "workspace", path })} /> +
+
+
+ setFilter(event.currentTarget.value)} /> + {commit => } +

{props.t("gitPanel.emptyHistory")}

+ +
+ + + {details => <> +
{details().id.slice(0, 8)}

{details().message}

{props.t("gitPanel.files", { count: details().files.length })}
+
+ {file => { + const target = () => ({ path: file.path, commit: details().id, subject: details().message.split("\n")[0] }) + return
+ + , + checked: previewActive(target()), disabled: !props.canOpenFile, onSelect: () => togglePreview(target()), + }]} /> +
+ }}
+
+ }
+
+

{props.t("instanceInfo.loading")}

+
+
+ + +

{props.git.gitStatusLoading() ? props.t("instanceInfo.loading") : props.t("instanceShell.gitChanges.empty")}

+ {section => + item.section === section)} + git={props.git} canOpenFile={props.canOpenFile} onOpenFile={togglePreview} previewActive={previewActive}> + +
+ {actionSection => { + const targets = createMemo(() => props.git.gitActionItems().filter(item => item.section === actionSection)) + const action = actionSection === "staged" ? "unstage" : "stage" + return 0}> + }} +
+