From e9cfc27b9103f5649bca52394a8c84d92c0caee3 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Fri, 6 Nov 2026 22:37:20 -0600 Subject: [PATCH 01/59] feat(studio): the window works on the sidecar's committed chats, and the claude -p runner is gone - The studio reads projects, drafts, and chats from the sidecar, and shows committed chat entries only. Drafts save as the person types. A send keeps one request id until it goes through, so sending again never sends twice. - A waiting turn shows its fix: a sign-in, a key or proxy form, or Try again. The header names the model the current turn runs with. - The update gate: the sidecar takes the pass before a send commits and gives it back once no task is live. Rust answers with the BuildGate, so an update waits for busy work and refuses a send while it installs. - Chats from before the store are imported once from each project's .m3d/transcript.jsonl. The files stay as they are. - Removed: the claude -p turn runner and its process-tree kill logic (studio/turn.rs), its lean config (studio/lean.rs), its system prompt, and the studio plugin resource. The file protocol, preview isolation, the navigation guard, the updater, the Keychain, and the Bambu slice step stay. - The update model maps a Studio turn to the sidecar's work. The TLA+ verdicts and mutants are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../formal-verify-materialize-3d/SKILL.md | 10 +- .../models/UpdateRestart.tla | 30 +- .../models/sources.sha256 | 3 +- .../verify-materialize-3d/features/studio.md | 32 +- .github/workflows/formal-verification.yml | 3 +- scripts/release/check-app-bundle.sh | 6 +- sidecar/src/app.ts | 8 +- sidecar/src/busy.ts | 63 ++ sidecar/src/chat.ts | 30 +- sidecar/src/legacy.ts | 93 ++ sidecar/src/protocol.ts | 9 + sidecar/src/store.ts | 21 + sidecar/src/transcript.ts | 4 +- .../2026-11-06-a-wall-clip/.m3d/project.json | 6 + .../.m3d/transcript.jsonl | 11 + sidecar/test/sidecar.ts | 28 +- sidecar/test/switch.test.ts | 80 ++ sidecar/test/waiting.test.ts | 2 +- src-tauri/build.rs | 5 - src-tauri/capabilities/default.json | 5 - src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/gen/schemas/desktop-schema.json | 60 -- src-tauri/gen/schemas/linux-schema.json | 60 -- .../studio-plugin/.claude-plugin/plugin.json | 5 - .../studio-plugin/skills/fabricate/SKILL.md | 33 - src-tauri/src/lib.rs | 9 +- src-tauri/src/sidecar.rs | 165 +++- src-tauri/src/studio/lean.rs | 374 -------- src-tauri/src/studio/mod.rs | 144 +-- src-tauri/src/studio/system_prompt.md | 22 - src-tauri/src/studio/turn.rs | 830 ------------------ src-tauri/tauri.conf.json | 3 +- src/studio/Chat.tsx | 2 - src/studio/StudioApp.test.tsx | 123 +-- src/studio/StudioApp.tsx | 168 ++-- src/studio/Waiting.tsx | 55 ++ src/studio/api.ts | 19 +- src/studio/studio.css | 23 + src/studio/transcript.test.ts | 84 +- src/studio/transcript.ts | 178 ++-- 41 files changed, 908 insertions(+), 1902 deletions(-) create mode 100644 sidecar/src/busy.ts create mode 100644 sidecar/src/legacy.ts create mode 100644 sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/project.json create mode 100644 sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/transcript.jsonl create mode 100644 sidecar/test/switch.test.ts delete mode 100644 src-tauri/resources/studio-plugin/.claude-plugin/plugin.json delete mode 100644 src-tauri/resources/studio-plugin/skills/fabricate/SKILL.md delete mode 100644 src-tauri/src/studio/lean.rs delete mode 100644 src-tauri/src/studio/system_prompt.md delete mode 100644 src-tauri/src/studio/turn.rs create mode 100644 src/studio/Waiting.tsx diff --git a/.agents/skills/formal-verify-materialize-3d/SKILL.md b/.agents/skills/formal-verify-materialize-3d/SKILL.md index b73db9f9..9a0ae0e9 100644 --- a/.agents/skills/formal-verify-materialize-3d/SKILL.md +++ b/.agents/skills/formal-verify-materialize-3d/SKILL.md @@ -7,7 +7,7 @@ description: Model-check, with TLC, what the in-app update's restart can cut off Two TLA+ models describe code where a bad order of events harms a user and tests rarely reach that order. TLC (the TLA+ model checker) tries every order of steps that the models allow. -- `models/UpdateRestart.tla`. **Update now** waits for builds, closes the build gate, backs up, installs, and restarts. Builds, STL slices, and Studio turns race against it, and so do print queue writes. +- `models/UpdateRestart.tla`. **Update now** waits for builds, closes the build gate, backs up, installs, and restarts. Builds, STL slices, and the sidecar's chat work race against it, and so do print queue writes. - `models/AutoSend.tla`. Two auto-send runs race to send the first Pending job in the print queue to the printer, across reconnects and a printer switch. ## Checks @@ -16,7 +16,7 @@ Two TLA+ models describe code where a bad order of events harms a user and tests | Check | Property | Harm if it breaks | Recorded | |---|---|---|---| -| `no-build-cut-off` | The restart never kills a build, a slice, or a Studio turn, nor cuts a turn's last transcript write. | A half-made build or 3MF, a turn's agent killed mid-job, or a chat that never records how its turn ended. | holds | +| `no-build-cut-off` | The restart never kills a build, a slice, or the sidecar's chat work, nor cuts its last commit. | A half-made build or 3MF, or a turn's agent killed mid-job. | holds | | `gate-held-until-restart` | From the idle check to the restart, the gate is closed and no build runs. | A build starts that the restart then kills. | holds | | `failed-update-reopens` | A failed update lets builds start again. | The app refuses every build until it is quit. | holds | | `update-wakes` | When builds stop starting, a waiting update goes ahead. | **Update now** hangs with no build running. | holds | @@ -55,8 +55,8 @@ Exit 0 means every verdict matches the recorded one. Exit 1 means at least one d | `split-close` | `close_when_idle` reads `running`, then closes the gate in a second send. | `gate-held-until-restart` | | `late-subscribe` | `borrow_and_update` moves after the idle check, so a build that ends in between is never heard. | `update-wakes` | | `ungated-slice` | `slice_model` no longer calls `builds.enter()`. | `no-build-cut-off` | -| `ungated-turn` | `studio_send` no longer calls `take_pass`. | `no-build-cut-off` | -| `early-release` | A Studio turn drops its pass before its last transcript write. | `no-build-cut-off` | +| `ungated-turn` | A send no longer asks for the pass (`Busy.during` in `sidecar/src/busy.ts`). | `no-build-cut-off` | +| `early-release` | The sidecar gives its pass back while a task is still live. | `no-build-cut-off` | | `guard-dropped` | `update_install` binds the `Closed` guard to `_`, which drops it before the restart. | `no-build-cut-off` | | `guard-leaked` | An error path keeps the `Closed` guard alive. | `failed-update-reopens` | | `unguarded` | The claim in `auto_send_pending_job` no longer checks `queue_held`. | `one-run-at-a-time` | @@ -78,7 +78,7 @@ Then read each covered function that changed. Also look for new code that reache | Model action | Code | |---|---| -| `Enter`, `Leave` | `BuildGate::enter` and `Drop for Running` in `build_gate.rs`. Callers: `build_as` in `pipeline.rs`, `slice_model` in `commands.rs`. For a Studio turn, `BuildGate::enter_owned` through `take_pass` in `studio_send`, and `Drop for OwnedRunning` at the end of the turn's task in `studio/turn.rs`, after its last transcript write. | +| `Enter`, `Leave` | `BuildGate::enter` and `Drop for Running` in `build_gate.rs`. Callers: `build_as` in `pipeline.rs`, `slice_model` in `commands.rs`. For the sidecar's chat work, `BuildGate::enter_owned` through `take_pass` in `set_busy` (`sidecar.rs`), which `sidecar/src/busy.ts` asks for before a send commits, and `Drop for OwnedRunning` when it gives the pass back once no task is live, after its last commit. | | `Downloaded`, `Top`, `CheckAndClose`, `CheckBusy`, `Wake` | `BuildGate::close_when_idle`. Its `subscribe`, `borrow_and_update`, one `send_if_modified`, and `changed().await`. | | `Reopen` | `Drop for Closed`, on each error return of `install_when_idle`. | | `Click`, `DownloadFails`, `BackupOk`, `BackupFails`, `InstallOk`, `InstallFails`, `Exit` | `update_install` and `install_when_idle` in `updater.rs`, then Tauri's `AppHandle::restart`, which ends in `process::exit(0)`. | diff --git a/.agents/skills/formal-verify-materialize-3d/models/UpdateRestart.tla b/.agents/skills/formal-verify-materialize-3d/models/UpdateRestart.tla index 5905e0b2..8ee6f729 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/UpdateRestart.tla +++ b/.agents/skills/formal-verify-materialize-3d/models/UpdateRestart.tla @@ -8,10 +8,11 @@ (* install_when_idle *) (* src-tauri/src/fabrication/pipeline.rs build_as takes a pass *) (* src-tauri/src/commands.rs slice_model takes a pass *) -(* src-tauri/src/studio/turn.rs a Studio turn takes a pass *) -(* in studio_send, before its *) -(* first transcript line, and *) -(* drops it after its last *) +(* src-tauri/src/sidecar.rs the sidecar's work takes a *) +(* sidecar/src/busy.ts pass (set_busy) before a *) +(* send's first commit, and *) +(* drops it once no task is *) +(* live, after its last commit *) (* src-tauri/src/print_queue.rs persist_queue *) (* Tauri's AppHandle::restart from an async command asks the event loop to *) (* exit, then calls std::process::exit(0), which stops every other thread *) @@ -22,7 +23,7 @@ EXTENDS Naturals CONSTANTS Builds, \* tasks that each hold at most one build pass at a time Slice, \* the member of Builds that is commands.rs slice_model - Turn, \* the member of Builds that is a Studio turn (studio/turn.rs) + Turn, \* the member of Builds that is the sidecar's work (busy.ts) MaxStarts, \* starts per build task. 0 means no limit. MaxUpdates, \* how many times update_install can run Mutant \* "none" is the code. The others are broken variants. @@ -36,7 +37,8 @@ VARIABLES closed, \* Builds.closed in the gate's watch channel unseen, \* the updater's watch::Receiver holds a change it has not seen build, \* build task -> "idle", "running", or "writing" (mutant - \* early-release: a turn still writing after its pass is gone) + \* early-release: the sidecar still committing after its pass + \* is gone) starts, \* build task -> starts so far, counted only when MaxStarts > 0 upd, \* where update_install is updates, \* update_install runs so far @@ -62,15 +64,15 @@ Init == ---------------------------------------------------------------------------- (* Builds. build_as and slice_model each call BuildGate::enter first and *) -(* hold the Running pass until they return. A Studio turn calls *) -(* BuildGate::enter_owned in studio_send and drops the pass at the end of *) -(* its task, after its last transcript write. *) +(* hold the Running pass until they return. The sidecar asks for a pass *) +(* (BuildGate::enter_owned in set_busy) before a send commits, and gives *) +(* it back once no task is live, after its last commit. *) Gated(b) == /\ ~(Mutant = "ungated-slice" /\ b = Slice) /\ ~(Mutant = "ungated-turn" /\ b = Turn) -\* Mutant early-release: a turn drops its pass before its last transcript write. +\* Mutant early-release: the sidecar drops its pass before its last commit. EarlyRelease(b) == Mutant = "early-release" /\ b = Turn CanStart(b) == @@ -105,8 +107,8 @@ Leave(b) == ELSE UNCHANGED <> /\ UNCHANGED <> -\* Mutant early-release: the pass drops while the turn still has its last -\* transcript lines to write. +\* Mutant early-release: the pass drops while the sidecar still has its last +\* commit to make. ReleaseEarly(b) == /\ Alive /\ build[b] = "running" /\ EarlyRelease(b) /\ build' = [build EXCEPT ![b] = "writing"] @@ -256,8 +258,8 @@ Spec == Init /\ [][Next]_vars /\ Fairness ---------------------------------------------------------------------------- (* Properties. Each is one check in scripts/check.sh. *) -\* The restart never kills a build, a slice, or a Studio turn, nor cuts a -\* turn's last transcript write. +\* The restart never kills a build, a slice, or the sidecar's work, nor cuts +\* its last commit. NoBuildCutOff == upd = "exited" => \A b \in Builds : build[b] = "idle" \* From the idle check to the restart, the gate is closed and no build runs. diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index a8bdde2a..52514183 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -4,4 +4,5 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ 581b0e828a547528a87785cd366c6e57252dea85823cc1402fcaf7b113a2255c src-tauri/src/printer/mod.rs dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs -10eff3df2dbaf0b024c18e97cb60b8961bdafbe8130d63f374a099606a687b0a src-tauri/src/studio/turn.rs +5f5db6fb99f087361d6f6aed902cff3820a7a5c89d78ec163939aa10617540b6 src-tauri/src/sidecar.rs +81aa665045becb8655949cf75d1c4807adb86f96425b7d74098e3e595fef17dc sidecar/src/busy.ts diff --git a/.agents/skills/verify-materialize-3d/features/studio.md b/.agents/skills/verify-materialize-3d/features/studio.md index fa2265ad..a5997af9 100644 --- a/.agents/skills/verify-materialize-3d/features/studio.md +++ b/.agents/skills/verify-materialize-3d/features/studio.md @@ -1,15 +1,16 @@ # Studio -The studio is the whole app. Each chat is a project folder in `~/Documents/Materialize 3D/-/` (an `e2e` build uses `/projects/`). A turn runs the local `claude` CLI with Opus 5.5 in that folder on the person's own login. The right panel shows whatever the agent writes: HTML in a sandboxed frame, STL, 3MF, and GLB in an orbit viewer, and pictures. The newest such file opens on its own. The Files tab lists every file, with Reveal in Finder and, for a 3MF, Open in Bambu Studio. The other recipes in this map describe screens the studio hides. +The studio is the whole app. Each chat is a project folder in `~/Documents/Materialize 3D/-/` (an `e2e` build uses `/projects/`), and a conversation in the sidecar's store, `/state/store.sqlite` (an `e2e` build: `/state/`). The sidecar, `Contents/MacOS/materialize-sidecar`, runs the agent through Pi Durable on the person's own inference login, with the app's own tools: `read`, `write`, and `edit` inside the project folder, `run` (uv), `slice` (the validated Bambu Studio, through Rust), and `astra` (GPT 6 Astra as a child task). The right panel shows whatever the agent writes: HTML in a sandboxed frame, STL, 3MF, and GLB in an orbit viewer, and pictures. The newest such file opens on its own. The Files tab lists every file, with Reveal in Finder and, for a 3MF, Open in Bambu Studio. The other recipes in this map describe screens the studio hides. ## Sub-features - `start-screen` holds "What are we making?", the `prompt` box, `Add picture`, `send`, and three suggestion chips that fill the box. -- `prompt` takes text, pasted pictures, and links. A picture dropped on the window or chosen with `Add picture` shows in `attachments` and is copied to the project's `refs/` on send. It reaches the model as an image block. -- `messages` shows each tool step on one line (`.tool`, with `.tool-running` while it runs), agent text, `file-cards` after a turn that wrote previewable files or a 3MF, and `.msg-error` for a failed or stopped turn. `working` shows while a turn runs, and `stop` replaces `send`. -- `stop` ends the agent and every tool it started (its process group). -- The agent runs lean: the person's inference login only (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `API_TIMEOUT_MS`, and `apiKeyHelper` from `~/.claude/settings.json`), `--setting-sources project`, no MCP servers, no CLAUDE.md or auto-memory, and the app's own `materialize-studio` plugin from the bundle's `studio-plugin` resource. Astra runs with `CODEX_HOME` set to `/astra`, which holds only the cliproxy provider and turns off the skills under `~/.agents/skills`. A turn's `system` `init` line in the transcript lists its tools, MCP servers, plugins, and skills. -- A turn cut off by quitting the app ends when the project reopens, with "This turn stopped when the app closed." +- `prompt` takes text, pasted pictures, and links. The text is saved as the project's draft a moment after typing stops, and comes back when the project opens. A picture dropped on the window or chosen with `Add picture` shows in `attachments` and is copied to the project's `refs/` on send. It reaches the model as an image block. +- `messages` shows committed chat lines only: each tool step on one line (`.tool`, with `.tool-running` until its result is committed), agent text, and `file-cards` after a turn that wrote previewable files or a 3MF. `working` shows while a turn runs, and `stop` replaces `send`. +- `chat-model` names the model the current turn runs with. A settings change applies from the next message. +- `waiting` shows when a turn stopped before its answer: the fix, and the control for it. A provider with no login shows `Sign in with Claude` or `Sign in with ChatGPT`, and for Anthropic and OpenCode Go a key form (`API key or token`, and for Anthropic `Proxy URL`). Once the login is saved, the turn goes on by itself. Any other failure shows its error and `Try again`. +- A turn cut off by quitting or a crash goes on from its last committed step when the app opens again. +- `engine` in the sidebar shows the sidecar's version, or why it stopped. - `files-tab` opens `files-list`, with "Print files" (3MF) above "All files". - `html-preview` is the iframe. `model-view` holds the orbit canvas. @@ -20,7 +21,7 @@ The studio is the whole app. Each chat is a project folder in `~/Documents/Mater ## Driving it with wd.ts -On macOS, drive it with `mac.ts` from an `e2e` build. Answer `Add picture` with `mac.ts dialog ` before the click. `PROJECT` is the one folder under `/data/projects/`. +On macOS, drive it with `mac.ts` from an `e2e` build. Answer `Add picture` with `mac.ts dialog ` before the click. A fresh data folder has no login, so the first turn waits for one. `ANTHROPIC_TOKEN` and `ANTHROPIC_PROXY` are the host's proxy token and URL. ```bash bun $S/mac.ts wait '[data-testid=start-screen]' 30000 @@ -28,20 +29,23 @@ bun $S/mac.ts dialog "$HOME/fixtures/sketch.png" bun $S/mac.ts click 'text=Add picture' bun $S/mac.ts type '[data-testid=prompt]' 'Make a cable clip like in this sketch, then slice it to a 3MF' bun $S/mac.ts click '[data-testid=send]' +bun $S/mac.ts wait '[data-testid=waiting]' 20000 +bun $S/mac.ts type '[aria-label="API key or token"]' "$ANTHROPIC_TOKEN" +bun $S/mac.ts type '[aria-label="Proxy URL"]' "$ANTHROPIC_PROXY" +bun $S/mac.ts click 'text=Save' bun $S/mac.ts wait '[data-testid=working]' 20000 -# A full job takes minutes. Wait for its end line in the transcript, then for the file card. -until grep -q m3d_turn_end "$PROJECT/.m3d/transcript.jsonl"; do sleep 10; done -bun $S/mac.ts wait '[data-testid=file-cards]' 30000 +# A full job takes minutes. The file cards appear when the turn's last step is committed. One mac.ts wait is one +# HTTP request, which Bun ends after about five minutes, so a long wait goes in rounds. +for _ in 1 2 3 4; do bun $S/mac.ts wait '[data-testid=file-cards]' 240000 && break; done bun $S/mac.ts click '[data-testid=files-tab]' bun $S/mac.ts click '[data-testid=files-list] button.primary' # Open in Bambu Studio ``` -The transcript is `/.m3d/transcript.jsonl`. A finished turn ends with an `m3d_turn_end` line. Wait on that line, not on a clock. - ## Gotchas -- A turn needs `claude` signed in on the host. Without it the turn ends at once with the CLI's error in `.msg-error`. -- Claude Code refuses a bare `sleep`. To test Stop, ask for `python3 -c 'import time; time.sleep(300)'` in the foreground **(observed 2026-11-06)**. +- The sidecar's stderr is in the app log, as `sidecar: `. Its first line lists the agent's tools. +- The sidecar reads none of the host's agent setup or environment. A login made with `claude` or `codex` on the host does not reach it. - `mac.ts` selectors are CSS or `text=`. It has no XPath. +- A `mac.ts wait` longer than about five minutes fails with "The operation timed out", because Bun's fetch ends the one request it makes **(observed 2026-11-07)**. Wait in rounds of four minutes. - The WebKit snapshot from `mac.ts shot` includes the iframe and the WebGL canvas **(observed 2026-11-06)**. - Bambu Studio's own window is outside the webview. Check its process with `pgrep -x BambuStudio` after Open in Bambu Studio. diff --git a/.github/workflows/formal-verification.yml b/.github/workflows/formal-verification.yml index 03d0c48d..6790ed3d 100644 --- a/.github/workflows/formal-verification.yml +++ b/.github/workflows/formal-verification.yml @@ -28,7 +28,8 @@ on: - src-tauri/src/printer/mod.rs - src-tauri/src/commands.rs - src-tauri/src/fabrication/pipeline.rs - - src-tauri/src/studio/turn.rs + - src-tauri/src/sidecar.rs + - sidecar/src/busy.ts - .agents/skills/formal-verify-materialize-3d/** - .github/workflows/formal-verification.yml push: diff --git a/scripts/release/check-app-bundle.sh b/scripts/release/check-app-bundle.sh index 0f870b3c..64f41dce 100755 --- a/scripts/release/check-app-bundle.sh +++ b/scripts/release/check-app-bundle.sh @@ -18,8 +18,8 @@ newline=$'\n' odd="$(cd "$app" && find . -name "*$newline*" -print -quit)" [[ -z "$odd" ]] || die "a path in the app bundle contains a newline: $(printf '%q' "$odd")" -# The app binary, the sidecar, the CAD helper and its runtime, the agent's studio plugin, the icons (Assets.car for -# macOS 26, icon.icns as the CFBundleIconFile), the license notices, the signature, and the stapled ticket +# The app binary, the sidecar, the CAD helper and its runtime, the icons (Assets.car for macOS 26, +# icon.icns as the CFBundleIconFile), the license notices, the signature, and the stapled ticket # (Contents/CodeResources). Every entry that is not a directory counts, symlinks included. expected="$(printf '%s\n' \ Contents/CodeResources Contents/Info.plist \ @@ -28,8 +28,6 @@ expected="$(printf '%s\n' \ Contents/Resources/THIRD_PARTY_NOTICES.md Contents/Resources/icon.icns \ Contents/Resources/cad-runtime/Image Contents/Resources/cad-runtime/job.img \ Contents/Resources/cad-runtime/rootfs.img \ - Contents/Resources/studio-plugin/.claude-plugin/plugin.json \ - Contents/Resources/studio-plugin/skills/fabricate/SKILL.md \ Contents/_CodeSignature/CodeResources | sort)" actual="$(cd "$app" && find . ! -type d | sed 's|^\./||' | sort)" [[ "$actual" == "$expected" ]] || die "unexpected files in the app bundle: diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index 26228e2a..c9e835f9 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -2,9 +2,11 @@ import { parseArgs } from 'node:util' import type { MutableModels } from '@earendil-works/pi-ai' import { createRegistry } from '@earendil-works/pi-durable' import { agentExtension, agentTools, ProjectEnv } from './agent' +import { Busy } from './busy' import { Chats } from './chat' import { Host } from './host' import { inferenceModels, KeychainCredentials, Logins } from './inference' +import { legacyProjects } from './legacy' import { handlers } from './methods' import { EXIT, type SidecarEvent } from './protocol' import { Store, StoreInUse } from './store' @@ -80,12 +82,16 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis chats.signedIn(event.provider).catch((error: unknown) => console.error(`resuming turns after a sign-in failed: ${String(error)}`)) } } - const chats = new Chats({ store, models, extension, tools, version, emit }) + const busy = new Busy(host) + const chats = new Chats({ store, models, extension, tools, version, busy, emit }) const logins = new Logins(models, credentials, emit) console.error(`agent tools: ${tools.map((tool) => tool.name).join(', ')}`) // The pipe opens before the reconcile, which asks Rust's Keychain whether a waited-on login is there now. const serving = host.serve(handlers({ store, logins, chats, version }), process.stdin) + const imported = await store.importLegacy(legacyProjects(projects)) + if (imported.length > 0) console.error(`imported ${imported.length} chats from before the store: ${imported.join(', ')}`) + await busy.watch(store.harness) host.emit({ type: 'ready', version }) await chats.reconcile() await serving diff --git a/sidecar/src/busy.ts b/sidecar/src/busy.ts new file mode 100644 index 00000000..837ded26 --- /dev/null +++ b/sidecar/src/busy.ts @@ -0,0 +1,63 @@ +/** + * Tells Rust while the store has work in flight, so an update waits to restart the app until the work is done. The + * pass is asked for before a turn's first commit, and given back once no task is live and no send is committing. + * While an update installs, Rust refuses the pass, and the send with it. + */ +import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' +import type { Harness } from '@earendil-works/pi-durable' +import type { Host } from './host' + +export class Busy { + readonly #host: Host + /** Whether Rust holds the pass for this sidecar. */ + #held = false + #tasks = 0 + #sends = 0 + /** Changes reach Rust one at a time, in order. */ + #told: Promise = Promise.resolve() + + constructor(host: Host) { + this.#host = host + } + + /** Follows the store's live tasks: a run, its tools, a child, a sleeping retry. */ + async watch(harness: Harness): Promise { + const graph = await harness.watchTaskGraph(BACKGROUND_CONTEXT) + graph.start(async (call) => { + if (call.kind !== 'value') return + this.#tasks = Object.keys(call.value.tasks).length + await this.#settle() + }) + } + + #settle(): Promise { + const busy = this.#tasks > 0 || this.#sends > 0 + if (busy === this.#held) return this.#told + this.#held = busy + this.#told = this.#told.then(() => this.#host.request('busy', { busy })).catch((error: unknown) => { + // A task that resumes while an update installs runs anyway: its work is committed step by step. + console.error(`the update gate refused the engine's pass: ${error instanceof Error ? error.message : String(error)}`) + this.#held = false + }) + return this.#told + } + + /** + * Holds the pass while `commit` runs, so a send never starts while an update installs. Throws Rust's refusal + * without committing anything. + */ + async during(commit: () => Promise): Promise { + this.#sends += 1 + try { + if (!this.#held) { + await this.#told + await this.#host.request('busy', { busy: true }) + this.#held = true + } + return await commit() + } finally { + this.#sends -= 1 + await this.#settle() + } + } +} diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 520a6199..c069f412 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -23,6 +23,7 @@ import { } from '@earendil-works/pi-durable' import type { ConversationView } from '@earendil-works/pi-durable' import { leaves, WORKFLOW, WORKFLOW_VERSION } from './agent' +import type { Busy } from './busy' import type { ChatEntry, ChatView, SidecarEvent, Turn, Waiting } from './protocol' import { isLoginFailure } from './inference' import { DraftsDoc, LoginSavesDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' @@ -40,6 +41,7 @@ export interface ChatsOptions { extension: Extension tools: readonly ToolRegistration[] version: string + busy: Busy emit(event: SidecarEvent): void } @@ -194,7 +196,7 @@ export class Chats { * once a picture it sent is gone. */ async send(project: string, requestId: string, text: string, attachments: string[]): Promise { - const { store, extension, tools, version } = this.#o + const { store, extension, tools, version, busy } = this.#o const conversation = await this.#conversation(project) if ((await store.harness.commit((tx) => tx.submissionByRequest(conversation.id, requestId), context)) !== undefined) { await this.#watch(project, conversation) @@ -203,7 +205,7 @@ export class Chats { const folder = store.projectFolder(project) const content = userContent(folder, text, attachments) try { - await store.harness.commit(async (tx) => { + await busy.during(() => store.harness.commit(async (tx) => { if ((await tx.submissionByRequest(conversation.id, requestId)) !== undefined) return const settings = await tx.doc(SettingsDoc, store.root.id) const model = { provider: settings.model.provider, modelId: settings.model.modelId } @@ -223,7 +225,7 @@ export class Chats { waiting.waiting = null waiting.tool = false waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[model.provider] ?? 0 - }, context) + }, context)) } catch (error) { if (error instanceof ConversationBusy) throw new Error('The agent is still working on the last message.') throw error @@ -238,16 +240,18 @@ export class Chats { async #continue(project: string): Promise { const { store, version } = this.#o const conversation = await this.#conversation(project) - await store.harness.commit(async (tx) => { - await conversation.submit(tx, { type: 'continue' }) - const waiting = await tx.doc(WaitingDoc, conversation.id) - waiting.waiting = null - const { turn } = await tx.doc(TurnDoc, conversation.id) - if (turn !== null) { - turn.app = version - waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[turn.model.provider] ?? 0 - } - }, context) + await this.#o.busy.during(() => + store.harness.commit(async (tx) => { + await conversation.submit(tx, { type: 'continue' }) + const waiting = await tx.doc(WaitingDoc, conversation.id) + waiting.waiting = null + const { turn } = await tx.doc(TurnDoc, conversation.id) + if (turn !== null) { + turn.app = version + waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[turn.model.provider] ?? 0 + } + }, context), + ) await this.#watch(project, conversation) } diff --git a/sidecar/src/legacy.ts b/sidecar/src/legacy.ts new file mode 100644 index 00000000..21565a88 --- /dev/null +++ b/sidecar/src/legacy.ts @@ -0,0 +1,93 @@ +/** + * Chats from before the store: each project folder kept its record in `.m3d/project.json` and its chat in + * `.m3d/transcript.jsonl`, Claude Code stream-json events plus the app's own `m3d_*` events. Each one is imported + * once, as display entries, and its files stay as they are. + */ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { ImportedEntry } from './protocol' + +/** How much of an old tool result the chat keeps. */ +const RESULT_LIMIT = 4_000 + +export type LegacyProject = { id: string; title: string; createdMs: number; entries: ImportedEntry[] } + +type Block = { type: string; text?: string; id?: string; name?: string; input?: Record; tool_use_id?: string; content?: unknown; is_error?: boolean } + +const str = (value: unknown) => (typeof value === 'string' ? value : '') + +function resultText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content.map((block: Block) => block.text ?? `[${block.type}]`).join('\n') +} + +/** What a tool step did, in a line: the description or command a shell step carried, or the path a file step used. */ +function summary(input: Record): string { + return str(input.description) || str(input.command).split('\n')[0] || str(input.file_path ?? input.path) || str(input.skill) +} + +/** The chat a transcript holds. A line it cannot read is a cut-off write and ends it, as the old app read them. */ +export function readTranscript(text: string): ImportedEntry[] { + const entries: ImportedEntry[] = [] + const tools = new Map>() + for (const line of text.split('\n')) { + if (line.trim() === '') continue + let event: Record + try { + event = JSON.parse(line) + } catch { + break + } + // A helper agent's inner steps stayed inside its one line. + if (event.parent_tool_use_id) continue + const blocks = ((event.message as { content?: Block[] | string } | undefined)?.content ?? []) as Block[] | string + switch (event.type) { + case 'm3d_user': + entries.push({ role: 'user', text: str(event.text), images: Array.isArray(event.images) ? event.images.map(str) : [] }) + break + case 'assistant': + if (typeof blocks === 'string') break + for (const block of blocks) { + if (block.type === 'text' && block.text?.trim()) entries.push({ role: 'assistant', text: block.text }) + if (block.type === 'tool_use' && block.id !== undefined && block.name !== undefined) { + const tool = { role: 'tool' as const, name: block.name, text: summary(block.input ?? {}), result: '', failed: false } + tools.set(block.id, tool) + entries.push(tool) + } + } + break + case 'user': + if (typeof blocks === 'string') break + for (const block of blocks) { + const tool = block.type === 'tool_result' && block.tool_use_id !== undefined ? tools.get(block.tool_use_id) : undefined + if (tool === undefined) continue + tool.result = resultText(block.content).slice(-RESULT_LIMIT) + tool.failed = block.is_error === true + } + break + case 'm3d_turn_end': + if (event.ok === false) entries.push({ role: 'assistant', text: `The turn stopped: ${str(event.error)}` }) + break + } + } + return entries +} + +/** Every old project in the projects folder that has a record and a transcript. */ +export function legacyProjects(projectsDir: string): LegacyProject[] { + if (!existsSync(projectsDir)) return [] + return readdirSync(projectsDir, { withFileTypes: true }).flatMap((dir) => { + const meta = join(projectsDir, dir.name, '.m3d') + if (!dir.isDirectory() || !existsSync(join(meta, 'project.json')) || !existsSync(join(meta, 'transcript.jsonl'))) return [] + const record = JSON.parse(readFileSync(join(meta, 'project.json'), 'utf8')) as { title?: unknown; createdMs?: unknown } + return [ + { + id: dir.name, + title: str(record.title) || dir.name, + createdMs: typeof record.createdMs === 'number' ? record.createdMs : 0, + entries: readTranscript(readFileSync(join(meta, 'transcript.jsonl'), 'utf8')), + }, + ] + }) +} diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index fa38cb51..31c1b902 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -48,8 +48,15 @@ export type LoginEvent = | { type: 'login'; provider: ProviderId; step: 'done' } | { type: 'login'; provider: ProviderId; step: 'failed'; detail: string } +/** A line of a chat from before the store, imported for show: the agent does not read it. */ +export type ImportedEntry = + | { role: 'user'; text: string; images: string[] } + | { role: 'assistant'; text: string } + | { role: 'tool'; name: string; text: string; result: string; failed: boolean } + /** One committed line of a chat. Tool calls fold under their assistant line; each result is its own line. */ export type ChatEntry = + | ({ id: number; kind: 'imported' } & ImportedEntry) | { id: number; kind: 'user'; text: string; images: string[] } | { id: number; kind: 'assistant'; text: string; calls: { id: string; name: string; args: unknown }[] } | { id: number; kind: 'tool'; callId: string; name: string; text: string; failed: boolean } @@ -106,6 +113,8 @@ export interface HostRequests { 'keychain.set': { params: { account: string; secret: string }; result: null } 'keychain.delete': { params: { account: string }; result: null } slice: { params: SliceParams; result: SliceResult } + /** Holds or gives back the update gate's pass. Refused while an update installs. */ + busy: { params: { busy: boolean }; result: null } } export type HostRequest = keyof HostRequests diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 7ef1e855..5d20d0f8 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -12,6 +12,7 @@ import type { Models } from '@earendil-works/pi-ai' import { type Conversation, defineDoc, type HarnessOptions, Harness, type Registry } from '@earendil-works/pi-durable' import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node' import type { LoginIndex } from './inference' +import type { LegacyProject } from './legacy' import type { AppState, Draft, LoginKind, Project, Settings, Setup, Turn, Waiting } from './protocol' const context = BACKGROUND_CONTEXT @@ -287,6 +288,26 @@ export class Store implements LoginIndex { return (await this.harness.snapshot(LoginSavesDoc, this.root.id, context))?.saves[provider] ?? 0 } + /** + * Imports chats from before the store, each with its conversation and its place in the index in one commit. A + * project already in the index was imported before, so each is imported once. + */ + async importLegacy(legacy: LegacyProject[]): Promise { + const imported: string[] = [] + for (const old of legacy) { + const done = await this.harness.commit(async (tx) => { + const index = await tx.doc(ProjectsDoc, this.root.id) + if (index.projects.some((p) => p.id === old.id)) return false + const conversation = await tx.createConversation({ ownership: { kind: 'ownerless' } }) + for (const data of old.entries) await tx.appendEntry(conversation.id, { kind: 'm3d.imported', data }) + index.projects.push({ id: old.id, conversation: conversation.id, title: old.title, createdMs: old.createdMs }) + return true + }, context) + if (done) imported.push(old.id) + } + return imported + } + /** The project's folder, made if it is missing. */ projectFolder(id: string): string { const dir = join(this.projectsDir, id) diff --git a/sidecar/src/transcript.ts b/sidecar/src/transcript.ts index d2dc46ba..39eb0b94 100644 --- a/sidecar/src/transcript.ts +++ b/sidecar/src/transcript.ts @@ -3,7 +3,7 @@ * nothing on screen can vanish after a crash. */ import type { EntryRecord } from '@earendil-works/pi-durable' -import type { ChatEntry } from './protocol' +import type { ChatEntry, ImportedEntry } from './protocol' type Content = string | readonly ({ type: string; text?: string } | { type: 'toolCall'; id: string; name: string; arguments: unknown })[] @@ -18,6 +18,8 @@ export function textOf(content: Content | undefined): string { export function chatEntry(entry: EntryRecord): ChatEntry | undefined { const message = entry.model?.[0] switch (entry.kind) { + case 'm3d.imported': + return { id: entry.id, kind: 'imported', ...(entry.data as ImportedEntry) } case 'm3d.user': { const data = entry.data as { text: string; images: string[] } return { id: entry.id, kind: 'user', text: data.text, images: data.images } diff --git a/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/project.json b/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/project.json new file mode 100644 index 00000000..553a01c3 --- /dev/null +++ b/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/project.json @@ -0,0 +1,6 @@ +{ + "title": "A wall clip for my cable", + "createdMs": 1794000000000, + "sessionId": "00000000-0000-4000-8000-000000000000", + "lean": true +} diff --git a/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/transcript.jsonl b/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/transcript.jsonl new file mode 100644 index 00000000..a9189ea3 --- /dev/null +++ b/sidecar/test/fixtures/legacy/2026-11-06-a-wall-clip/.m3d/transcript.jsonl @@ -0,0 +1,11 @@ +{"type":"m3d_user","text":"A wall clip for my cable","images":["refs/cable.jpg"]} +{"type":"system","subtype":"init","session_id":"s"} +{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"..."}]}} +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"uv run build/model.py","description":"Build the clip"}}]}} +{"type":"assistant","parent_tool_use_id":"t1","message":{"content":[{"type":"text","text":"a helper's inner step"}]}} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"clip.stl written","is_error":false}]}} +{"type":"assistant","message":{"content":[{"type":"text","text":"The clip is in clip.stl."}]}} +{"type":"m3d_files","files":["clip.stl"]} +{"type":"m3d_turn_end","ok":true} +{"type":"m3d_user","text":"Make it thicker","images":[]} +{"type":"m3d_turn_end","ok":false,"error":"This turn stopped when the app closed."} diff --git a/sidecar/test/sidecar.ts b/sidecar/test/sidecar.ts index 5f5a5d97..928bfe8b 100644 --- a/sidecar/test/sidecar.ts +++ b/sidecar/test/sidecar.ts @@ -2,15 +2,16 @@ type Message = Record /** * The sidecar as Rust runs it: a child process spoken to over stdin and stdout, one JSON object per line. The test - * plays Rust. Like Rust, it reads stdout all the time, since the sidecar stops when no one drains it, and it answers - * the sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain. `gate` may hold a request's - * answer, read when the request came, until it calls `answer`, as a slow Keychain does. + * plays Rust. Like Rust, it reads stdout all the time, since the sidecar stops when no one drains it. It answers the + * sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain, and its update-gate requests, + * recording each in `gate` and refusing the pass while `gate.installing` is set, as an installing update does. `hold` + * may hold a request's answer, read when the request came, until it calls `answer`, as a slow Keychain does. */ export function spawnSidecar( dataDir: string, projectsDir: string, keychain = new Map(), - options: { script?: string; env?: Record; version?: string; gate?: (request: Message, answer: () => void) => void } = {}, + options: { script?: string; env?: Record; version?: string; hold?: (request: Message, answer: () => void) => void } = {}, ) { const entry = options.script === undefined ? 'src/main.ts' : 'test/fixtures/scripted-sidecar.ts' const child = Bun.spawn(['bun', entry, '--app-version', options.version ?? '9.9.9', '--data', dataDir, '--projects', projectsDir], { @@ -21,9 +22,20 @@ export function spawnSidecar( stderr: 'pipe', }) const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`) + const gate = { installing: false, requests: [] as boolean[] } - /** Reads the Keychain now, and returns how to send that answer, now or later. */ + /** Reads the Keychain or the gate now, and returns how to send that answer, now or later. */ function answer(request: Message): () => void { + if (request.method === 'busy') { + const { busy } = request.params as { busy: boolean } + gate.requests.push(busy) + return () => + send( + busy && gate.installing + ? { kind: 'answer', id: request.id, ok: false, error: 'An update is installing.' } + : { kind: 'answer', id: request.id, ok: true, value: null }, + ) + } const params = request.params as { account: string; secret?: string } const value = request.method === 'keychain.get' @@ -53,8 +65,8 @@ export function spawnSidecar( buffered = buffered.slice(end + 1) if (message.kind === 'request') { const reply = answer(message) - if (options.gate === undefined) reply() - else options.gate(message, reply) + if (options.hold === undefined) reply() + else options.hold(message, reply) } else inbox.push(message) } @@ -115,5 +127,5 @@ export function spawnSidecar( await stderrDone return errors } - return { child, next, send, call, until, quit, stderr } + return { child, next, send, call, until, quit, stderr, gate } } diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts new file mode 100644 index 00000000..bbf7f899 --- /dev/null +++ b/sidecar/test/switch.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test' +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChatEntry, ChatView } from '../src/protocol' +import { spawnSidecar } from './sidecar' + +const LEGACY = join(import.meta.dir, 'fixtures', 'legacy') + +describe('chats from before the store', () => { + test('are imported once, as they were, and their files stay as they are', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) + const projects = join(dir, 'projects') + cpSync(LEGACY, projects, { recursive: true }) + const transcript = join(projects, '2026-11-06-a-wall-clip', '.m3d', 'transcript.jsonl') + const before = readFileSync(transcript) + + const first = spawnSidecar(join(dir, 'state'), projects) + await first.next() + const state = (await first.call('state.get')) as { projects: { id: string; title: string }[] } + expect(state.projects).toEqual([expect.objectContaining({ id: '2026-11-06-a-wall-clip', title: 'A wall clip for my cable', createdMs: 1794000000000 })]) + const view = (await first.call('chat.open', { project: '2026-11-06-a-wall-clip' })) as ChatView + const shown = view.entries.map(({ id: _, ...entry }) => entry) + expect(shown).toEqual([ + { kind: 'imported', role: 'user', text: 'A wall clip for my cable', images: ['refs/cable.jpg'] }, + { kind: 'imported', role: 'tool', name: 'Bash', text: 'Build the clip', result: 'clip.stl written', failed: false }, + { kind: 'imported', role: 'assistant', text: 'The clip is in clip.stl.' }, + { kind: 'imported', role: 'user', text: 'Make it thicker', images: [] }, + { kind: 'imported', role: 'assistant', text: 'The turn stopped: This turn stopped when the app closed.' }, + ]) + await first.quit() + + const second = spawnSidecar(join(dir, 'state'), projects) + await second.next() + expect(((await second.call('state.get')) as { projects: unknown[] }).projects).toHaveLength(1) + expect(((await second.call('chat.open', { project: '2026-11-06-a-wall-clip' })) as ChatView).entries).toEqual(view.entries) + await second.quit() + expect(readFileSync(transcript).equals(before)).toBe(true) + expect(existsSync(join(projects, '2026-11-06-a-wall-clip', '.m3d', 'project.json'))).toBe(true) + }) +}) + +describe('the update gate', () => { + function scratch() { + const dir = mkdtempSync(join(tmpdir(), 'm3d-gate-')) + const bin = join(dir, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'uv'), '#!/bin/sh\n[ "$1" = run ] && shift\nexec "$@"\n') + chmodSync(join(bin, 'uv'), 0o755) + return { data: join(dir, 'state'), projects: join(dir, 'projects'), env: { PATH: `${bin}:${process.env.PATH}` } } + } + + test('holds the pass from before a turn commits until its work is done, and a send is refused while an update installs', async () => { + const where = scratch() + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + expect(app.gate.requests).toEqual([]) + + await app.call('chat.send', { project, requestId: 'r1', text: 'please wait', attachments: [] }) + expect(app.gate.requests).toEqual([true]) + const started = join(where.projects, project, 'started') + while (!existsSync(started)) await Bun.sleep(25) + expect(app.gate.requests).toEqual([true]) + writeFileSync(join(where.projects, project, 'go'), '') + await app.until((event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant')) + while (app.gate.requests.at(-1) !== false) await Bun.sleep(25) + expect(app.gate.requests).toEqual([true, false]) + + app.gate.installing = true + await app.call('drafts.set', { key: project, text: 'make it red', attachments: [] }) + await expect(app.call('chat.send', { project, requestId: 'r2', text: 'make it red', attachments: [] })).rejects.toThrow('An update is installing.') + const view = (await app.call('chat.open', { project })) as ChatView + expect(view.entries.filter((entry) => entry.kind === 'user')).toHaveLength(1) + expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: [] }) + await app.quit() + }) +}) diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts index fed44648..af471d6f 100644 --- a/sidecar/test/waiting.test.ts +++ b/sidecar/test/waiting.test.ts @@ -107,7 +107,7 @@ describe('a turn whose provider is not signed in', () => { let holding = true const held: (() => void)[] = [] const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { - gate: (request, answer) => (holding && request.method === 'keychain.get' ? held.push(answer) : answer()), + hold: (request, answer) => (holding && request.method === 'keychain.get' ? held.push(answer) : answer()), }) await app.next() const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 5bcf3c1e..35482e03 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -94,11 +94,6 @@ const APP_COMMANDS: &[&str] = &[ "clear_bambu_studio", "update_check", "update_install", - "studio_projects", - "studio_create_project", - "studio_transcript", - "studio_send", - "studio_cancel", "studio_files", "studio_import_file", "studio_save_bytes", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7f181967..e2dbff83 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -87,11 +87,6 @@ "allow-clear-bambu-studio", "allow-update-check", "allow-update-install", - "allow-studio-projects", - "allow-studio-create-project", - "allow-studio-transcript", - "allow-studio-send", - "allow-studio-cancel", "allow-studio-files", "allow-studio-import-file", "allow-studio-save-bytes", diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 33e598e0..293e8260 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-cancel":{"identifier":"allow-studio-cancel","description":"Enables the studio_cancel command without any pre-configured scope.","commands":{"allow":["studio_cancel"],"deny":[]}},"allow-studio-create-project":{"identifier":"allow-studio-create-project","description":"Enables the studio_create_project command without any pre-configured scope.","commands":{"allow":["studio_create_project"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-projects":{"identifier":"allow-studio-projects","description":"Enables the studio_projects command without any pre-configured scope.","commands":{"allow":["studio_projects"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-studio-send":{"identifier":"allow-studio-send","description":"Enables the studio_send command without any pre-configured scope.","commands":{"allow":["studio_send"],"deny":[]}},"allow-studio-transcript":{"identifier":"allow-studio-transcript","description":"Enables the studio_transcript command without any pre-configured scope.","commands":{"allow":["studio_transcript"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-cancel":{"identifier":"deny-studio-cancel","description":"Denies the studio_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_cancel"]}},"deny-studio-create-project":{"identifier":"deny-studio-create-project","description":"Denies the studio_create_project command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_create_project"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-projects":{"identifier":"deny-studio-projects","description":"Denies the studio_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_projects"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-studio-send":{"identifier":"deny-studio-send","description":"Denies the studio_send command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_send"]}},"deny-studio-transcript":{"identifier":"deny-studio-transcript","description":"Denies the studio_transcript command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_transcript"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"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","allow-supports-multiple-windows"]},"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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"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-icon-with-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-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_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-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_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-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"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-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"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-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"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","allow-supports-multiple-windows"]},"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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"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-icon-with-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-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_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-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_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-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"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-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"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-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 1b69c0bb..bb422f39 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-projects","allow-studio-create-project","allow-studio-transcript","allow-studio-send","allow-studio-cancel","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index cc98244b..cef9b018 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -764,18 +764,6 @@ "const": "allow-store-credential", "markdownDescription": "Enables the store_credential command without any pre-configured scope." }, - { - "description": "Enables the studio_cancel command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-cancel", - "markdownDescription": "Enables the studio_cancel command without any pre-configured scope." - }, - { - "description": "Enables the studio_create_project command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-create-project", - "markdownDescription": "Enables the studio_create_project command without any pre-configured scope." - }, { "description": "Enables the studio_files command without any pre-configured scope.", "type": "string", @@ -794,12 +782,6 @@ "const": "allow-studio-open-in-bambu", "markdownDescription": "Enables the studio_open_in_bambu command without any pre-configured scope." }, - { - "description": "Enables the studio_projects command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-projects", - "markdownDescription": "Enables the studio_projects command without any pre-configured scope." - }, { "description": "Enables the studio_reveal command without any pre-configured scope.", "type": "string", @@ -812,18 +794,6 @@ "const": "allow-studio-save-bytes", "markdownDescription": "Enables the studio_save_bytes command without any pre-configured scope." }, - { - "description": "Enables the studio_send command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-send", - "markdownDescription": "Enables the studio_send command without any pre-configured scope." - }, - { - "description": "Enables the studio_transcript command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-transcript", - "markdownDescription": "Enables the studio_transcript command without any pre-configured scope." - }, { "description": "Enables the switch_printer command without any pre-configured scope.", "type": "string", @@ -1286,18 +1256,6 @@ "const": "deny-store-credential", "markdownDescription": "Denies the store_credential command without any pre-configured scope." }, - { - "description": "Denies the studio_cancel command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-cancel", - "markdownDescription": "Denies the studio_cancel command without any pre-configured scope." - }, - { - "description": "Denies the studio_create_project command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-create-project", - "markdownDescription": "Denies the studio_create_project command without any pre-configured scope." - }, { "description": "Denies the studio_files command without any pre-configured scope.", "type": "string", @@ -1316,12 +1274,6 @@ "const": "deny-studio-open-in-bambu", "markdownDescription": "Denies the studio_open_in_bambu command without any pre-configured scope." }, - { - "description": "Denies the studio_projects command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-projects", - "markdownDescription": "Denies the studio_projects command without any pre-configured scope." - }, { "description": "Denies the studio_reveal command without any pre-configured scope.", "type": "string", @@ -1334,18 +1286,6 @@ "const": "deny-studio-save-bytes", "markdownDescription": "Denies the studio_save_bytes command without any pre-configured scope." }, - { - "description": "Denies the studio_send command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-send", - "markdownDescription": "Denies the studio_send command without any pre-configured scope." - }, - { - "description": "Denies the studio_transcript command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-transcript", - "markdownDescription": "Denies the studio_transcript command without any pre-configured scope." - }, { "description": "Denies the switch_printer command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json index cc98244b..cef9b018 100644 --- a/src-tauri/gen/schemas/linux-schema.json +++ b/src-tauri/gen/schemas/linux-schema.json @@ -764,18 +764,6 @@ "const": "allow-store-credential", "markdownDescription": "Enables the store_credential command without any pre-configured scope." }, - { - "description": "Enables the studio_cancel command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-cancel", - "markdownDescription": "Enables the studio_cancel command without any pre-configured scope." - }, - { - "description": "Enables the studio_create_project command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-create-project", - "markdownDescription": "Enables the studio_create_project command without any pre-configured scope." - }, { "description": "Enables the studio_files command without any pre-configured scope.", "type": "string", @@ -794,12 +782,6 @@ "const": "allow-studio-open-in-bambu", "markdownDescription": "Enables the studio_open_in_bambu command without any pre-configured scope." }, - { - "description": "Enables the studio_projects command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-projects", - "markdownDescription": "Enables the studio_projects command without any pre-configured scope." - }, { "description": "Enables the studio_reveal command without any pre-configured scope.", "type": "string", @@ -812,18 +794,6 @@ "const": "allow-studio-save-bytes", "markdownDescription": "Enables the studio_save_bytes command without any pre-configured scope." }, - { - "description": "Enables the studio_send command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-send", - "markdownDescription": "Enables the studio_send command without any pre-configured scope." - }, - { - "description": "Enables the studio_transcript command without any pre-configured scope.", - "type": "string", - "const": "allow-studio-transcript", - "markdownDescription": "Enables the studio_transcript command without any pre-configured scope." - }, { "description": "Enables the switch_printer command without any pre-configured scope.", "type": "string", @@ -1286,18 +1256,6 @@ "const": "deny-store-credential", "markdownDescription": "Denies the store_credential command without any pre-configured scope." }, - { - "description": "Denies the studio_cancel command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-cancel", - "markdownDescription": "Denies the studio_cancel command without any pre-configured scope." - }, - { - "description": "Denies the studio_create_project command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-create-project", - "markdownDescription": "Denies the studio_create_project command without any pre-configured scope." - }, { "description": "Denies the studio_files command without any pre-configured scope.", "type": "string", @@ -1316,12 +1274,6 @@ "const": "deny-studio-open-in-bambu", "markdownDescription": "Denies the studio_open_in_bambu command without any pre-configured scope." }, - { - "description": "Denies the studio_projects command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-projects", - "markdownDescription": "Denies the studio_projects command without any pre-configured scope." - }, { "description": "Denies the studio_reveal command without any pre-configured scope.", "type": "string", @@ -1334,18 +1286,6 @@ "const": "deny-studio-save-bytes", "markdownDescription": "Denies the studio_save_bytes command without any pre-configured scope." }, - { - "description": "Denies the studio_send command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-send", - "markdownDescription": "Denies the studio_send command without any pre-configured scope." - }, - { - "description": "Denies the studio_transcript command without any pre-configured scope.", - "type": "string", - "const": "deny-studio-transcript", - "markdownDescription": "Denies the studio_transcript command without any pre-configured scope." - }, { "description": "Denies the switch_printer command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/resources/studio-plugin/.claude-plugin/plugin.json b/src-tauri/resources/studio-plugin/.claude-plugin/plugin.json deleted file mode 100644 index 79a62e5e..00000000 --- a/src-tauri/resources/studio-plugin/.claude-plugin/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "materialize-studio", - "version": "0.1.0", - "description": "The workflow Materialize 3D's agent follows to turn a request into a print-ready 3MF. Ships inside the app." -} diff --git a/src-tauri/resources/studio-plugin/skills/fabricate/SKILL.md b/src-tauri/resources/studio-plugin/skills/fabricate/SKILL.md deleted file mode 100644 index d550e911..00000000 --- a/src-tauri/resources/studio-plugin/skills/fabricate/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: fabricate -description: Turn a request for a printed part into params, a checked model, a preview, an HTML guide, and a print-ready 3MF for a Bambu Lab P2S. Use for every job in Materialize 3D. ---- - -# Fabricate a part - -The end of every job is a print-ready 3MF file. Everything else supports that. Work in the project folder, which is your working directory. - -## Steps - -1. **Parameters.** Write the design as numbers in `params.json`. Mark a measurement you do not know as `"TBD"` with your best estimate beside it, so the person does not have to measure before they see anything. Ask only for what you cannot estimate. -2. **Model and fit checks.** Write Python build scripts in `build/` that read `params.json`. Build the parts with manifold3d (`uv run --with manifold3d --with numpy python build/model.py`). Check that the parts are manifold, that parts that should not touch do not overlap, and that clearances match the parameters. Say what you checked and the numbers. -3. **Preview.** Export the parts to STL (or GLB) in the project folder so the person can turn them in the viewer. When they ask to see a model before the 3MF, this is the step they mean. -4. **HTML guide.** Write a self-contained HTML page: a technical drawing with dimensions, an interactive three.js assembly guide, or both, as the job needs. -5. **Slice to 3MF.** Slice with the Bambu Studio CLI for the P2S and write print-ready 3MF files into `print/`. - - On macOS the CLI is `/Applications/BambuStudio.app/Contents/MacOS/BambuStudio`. Its system presets are in `~/Library/Application Support/BambuStudio/system/BBL`, or `/Applications/BambuStudio.app/Contents/Resources/profiles/BBL` before Bambu Studio has been opened once. - - The CLI does not follow a preset's `inherits` chain, so merge each preset with its parents into a full JSON file first. - - Default presets: machine `Bambu Lab P2S 0.4 nozzle`, process `0.20mm Standard @BBL P2S`, filament `Bambu PETG Basic @BBL P2S 0.4 nozzle`, unless the person names others. - - Check that the bed is 256 x 256 mm after the merge. - - If Bambu Studio is not installed on this machine, say so plainly and leave the STL files ready. - -Keep each step's script, so a change to `params.json` rebuilds everything. - -## Astra, the GPT helper - -You can hand a task to Astra through the codex CLI. Astra suits drawings and second opinions. Run it from the project folder: - -``` -codex exec --skip-git-repo-check --ephemeral --sandbox workspace-write "" -``` - -Add `-i ` to give Astra a picture. Astra cannot see this chat, so the task must say everything it needs. Check what Astra writes before you show it. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 58b04025..c5bc0cd5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,7 +74,6 @@ pub fn run() { .manage(OpenScadService::default()) .manage(agent::commands::AgentTurns::default()) .manage(updater::PendingUpdate::default()) - .manage(studio::Turns::default()) .manage(sidecar::Sidecar::default()) .invoke_handler(tauri::generate_handler![ get_app_state, @@ -153,11 +152,6 @@ pub fn run() { studio_commands::clear_bambu_studio, updater::update_check, updater::update_install, - studio::studio_projects, - studio::studio_create_project, - studio::turn::studio_transcript, - studio::turn::studio_send, - studio::turn::studio_cancel, studio::files::studio_files, studio::files::studio_import_file, studio::files::studio_save_bytes, @@ -344,9 +338,8 @@ pub fn run() { .build(app_context()) .expect("error while building tauri application") .run(|app, event| { - // A turn running when the app quits would keep changing its project with no window to show it. + // The sidecar finishes its commits and exits before the app does. if let tauri::RunEvent::Exit = event { - app.state::().stop_all(); app.state::().shutdown(); } }); diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 07c07754..95473ca0 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::future::Future; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use serde::de::DeserializeOwned; @@ -21,6 +21,8 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{mpsc, oneshot, watch, Notify, OnceCell}; use crate::credentials; +use crate::fabrication::build_gate::{BuildGate, OwnedRunning}; +use crate::state::AppState; use crate::studio; /// The one event the window listens to. The sidecar's own events and the engine's state both arrive here. @@ -57,6 +59,8 @@ pub struct Sidecar { kill: Notify, /// Each slice by its operation id, running or done, for as long as the app runs. slices: Operations, + /// The update gate's pass, held while the sidecar has work in flight. + pass: Mutex>>, } /// Outside work by operation id: one result each, shared by every request that names the id. @@ -74,6 +78,24 @@ where cell.get_or_init(work).await.clone() } +/// The gate inside the app's state, so the sidecar's pass can own its way to it. +struct StateGate(Arc); + +impl std::ops::Deref for StateGate { + type Target = BuildGate; + fn deref(&self) -> &BuildGate { + &self.0.builds + } +} + +/// The pass that holds an update off while the sidecar works. The updater restarts the app only once no build +/// and no sidecar work holds one. While an update installs, the gate is closed and the pass, and with it a send, +/// is refused. +fn take_pass(state: Arc) -> Result, String> { + BuildGate::enter_owned(StateGate(state)) + .ok_or_else(|| "An update is installing, and the app restarts when it is done. Send this again after it opens.".to_owned()) +} + impl Default for Sidecar { fn default() -> Self { Self { @@ -84,6 +106,7 @@ impl Default for Sidecar { alive: watch::Sender::new(false), kill: Notify::new(), slices: Mutex::default(), + pass: Mutex::default(), } } } @@ -170,10 +193,84 @@ fn binary_path() -> Result { Ok(dir.join(if cfg!(windows) { format!("{BINARY}.exe") } else { BINARY.to_owned() })) } +/// How long the login shell may take to tell its PATH before the app goes on without it. +const LOGIN_SHELL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); + +/// The PATH of the person's login shell. An app opened from Finder starts with +/// only the system folders, where `uv` is not. +fn login_path() -> &'static str { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| { + let current = std::env::var("PATH").unwrap_or_default(); + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_owned()); + match shell_path(&shell, LOGIN_SHELL_DEADLINE) { + Some(path) if current.is_empty() => path, + Some(path) => format!("{path}:{current}"), + None => current, + } + }) +} + +/// The PATH `shell` reports as a login shell, or none when it fails, says nothing, or does not end within +/// `deadline`. A shell whose startup files hang is ended with all it started, so the sidecar and the runner still +/// start. +fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { + let marker = "__M3D_PATH__"; + let end = std::time::Instant::now() + deadline; + let mut command = std::process::Command::new(shell); + command + .args(["-ilc", &format!("printf '{marker}%s' \"$PATH\"")]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + // The shell leads a session of its own, so the deadline ends what its startup files started too. A session, not + // only a group: a shell in a background group of the terminal the app started from stops before its startup + // files, and one in a session of its own has no terminal to stop for. + #[cfg(unix)] + // SAFETY: setsid is async-signal-safe and only makes the child a session leader before it runs the shell. + unsafe { + std::os::unix::process::CommandExt::pre_exec(&mut command, || { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command.spawn().ok()?; + let mut stdout = child.stdout.take()?; + let (sent, said) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut out = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stdout, &mut out); + let _ = sent.send(out); + }); + let out = said.recv_timeout(deadline).ok(); + // Startup files can send stdout elsewhere and go on running, so the shell's end has the same deadline. + let ended = loop { + match child.try_wait() { + Ok(Some(_)) => break true, + Ok(None) if std::time::Instant::now() < end => std::thread::sleep(std::time::Duration::from_millis(20)), + _ => break false, + } + }; + if !ended { + #[cfg(unix)] + if let Ok(group) = i32::try_from(child.id()) { + // SAFETY: killpg only sends a signal, to the group this shell leads, as its session leader, and has not been + // reaped from. + unsafe { libc::killpg(group, libc::SIGKILL) }; + } + let _ = child.kill(); + let _ = child.wait(); + } + let out = out?; + String::from_utf8_lossy(&out).rsplit_once(marker).map(|(_, path)| path.trim().to_owned()).filter(|path| !path.is_empty()) +} + /// The only variables the sidecar gets. Its tools need a PATH that finds `uv` and a HOME for /// uv's cache. The person's agent and inference settings (`ANTHROPIC_API_KEY` and the like) stay out. fn kept_env() -> Vec<(&'static str, String)> { - let mut env = vec![("PATH", studio::turn::login_path().to_owned())]; + let mut env = vec![("PATH", login_path().to_owned())]; for name in ["HOME", "USER", "LANG", "TMPDIR"] { if let Ok(value) = std::env::var(name) { env.push((name, value)); @@ -280,6 +377,9 @@ async fn run(app: &AppHandle) -> Result { writer.abort(); let _ = writer.await; sidecar.drop_calls(); + // Work a sidecar was doing when it ended no longer holds an update off. Its replacement asks again for work + // it resumes. + *sidecar.pass.lock().unwrap_or_else(|e| e.into_inner()) = None; // A sidecar can close its stdout and still hang, so the wait still answers a kill. let status = loop { tokio::select! { @@ -372,6 +472,22 @@ struct SliceCall { request: studio::slice::Request, } +#[derive(Deserialize)] +struct Busy { + busy: bool, +} + +/// Takes the update gate's pass for the sidecar, or gives it back. Taking it again while held changes nothing. +fn set_busy(sidecar: &Sidecar, state: &Arc, busy: bool) -> Result<(), String> { + let mut pass = sidecar.pass.lock().unwrap_or_else(|e| e.into_inner()); + match (busy, pass.is_some()) { + (true, false) => *pass = Some(take_pass(state.clone())?), + (false, true) => *pass = None, + _ => {} + } + Ok(()) +} + #[derive(Deserialize)] struct Account { account: String, @@ -413,6 +529,11 @@ async fn answer(app: &AppHandle, method: &str, params: Value) -> Result { + let Busy { busy } = parse(method, params)?; + set_busy(&app.state::(), app.state::>().inner(), busy)?; + Ok(Value::Null) + } "slice" => { let SliceCall { operation, request } = parse(method, params)?; let cache = app.path().app_cache_dir().map_err(|e| format!("finding the app's cache folder: {e}"))?.join("bambu-presets"); @@ -455,6 +576,27 @@ pub async fn sidecar(sidecar: State<'_, Sidecar>, method: String, params: Value) mod tests { use super::*; + #[cfg(unix)] + #[test] + fn a_login_shell_that_hangs_is_ended_at_its_deadline() { + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("slow-shell"); + std::fs::write(&shell, "#!/bin/sh\nsleep 30\n").unwrap(); + let mut perms = std::fs::metadata(&shell).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&shell, perms).unwrap(); + let started = std::time::Instant::now(); + assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_millis(300)), None); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + + // Startup files that send stdout elsewhere and then hang end at the deadline too. + std::fs::write(&shell, "#!/bin/sh\nexec >/dev/null\nsleep 30\n").unwrap(); + let started = std::time::Instant::now(); + assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_millis(300)), None); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + assert!(shell_path("/bin/sh", std::time::Duration::from_secs(5)).is_some_and(|path| !path.is_empty())); + } + #[test] fn the_sidecar_reaches_only_inference_items_in_the_keychain() { assert_eq!(keychain_item("anthropic").as_deref(), Ok("inference.anthropic")); @@ -502,6 +644,25 @@ mod tests { assert!(serde_json::from_value::(json!({ "project": "p", "inputs": ["a.stl"], "output": "out.3mf" })).is_err()); } + /// The pass a busy sidecar takes holds an update off until it gives it back, and while the update installs + /// the pass, and with it a send, is refused. + #[tokio::test] + async fn an_update_waits_for_a_busy_sidecar_and_refuses_it_work_while_it_installs() { + const SETTLE: Duration = Duration::from_millis(50); + let state = Arc::new(AppState::default()); + let sidecar = Sidecar::default(); + set_busy(&sidecar, &state, true).expect("the sidecar takes the pass"); + set_busy(&sidecar, &state, true).expect("asking again while it holds the pass changes nothing"); + let mut close = std::pin::pin!(state.builds.close_when_idle(|_| {})); + assert!(tokio::time::timeout(SETTLE, &mut close).await.is_err(), "the update waits while the sidecar is busy"); + set_busy(&sidecar, &state, false).expect("the sidecar gives the pass back"); + let closed = tokio::time::timeout(SETTLE, &mut close).await.expect("the update goes ahead once the sidecar is idle"); + let refused = set_busy(&sidecar, &state, true).expect_err("work is refused while the update installs"); + assert!(refused.contains("update is installing"), "{refused}"); + drop(closed); + set_busy(&sidecar, &state, true).expect("a failed install lets the sidecar work again"); + } + #[test] fn a_protocol_line_parses_into_its_kind() { let request: Outbound = serde_json::from_str(r#"{"kind":"request","id":3,"method":"keychain.get","params":{"account":"anthropic"}}"#).expect("request"); diff --git a/src-tauri/src/studio/lean.rs b/src-tauri/src/studio/lean.rs deleted file mode 100644 index 4b248400..00000000 --- a/src-tauri/src/studio/lean.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! The agent's own environment. A turn runs on the person's inference login -//! and nothing else of their coding setup: no user settings, hooks, plugins, -//! skills, CLAUDE.md, or MCP servers, and for Astra no AGENTS.md, skills, or -//! MCP servers either. The workflow ships inside the app as a plugin. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use serde::Deserialize; - -/// The only keys taken from the person's Claude Code settings: how inference -/// is reached. A token is passed to the agent's process and never stored. -const CLAUDE_INFERENCE_KEYS: [&str; 5] = - ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "API_TIMEOUT_MS"]; - -/// The only variables the agent's process keeps from the app's own environment. -/// The app may have been started from a shell full of the person's setup, such -/// as `CLAUDE_CONFIG_DIR` or `CLAUDE_ENV_FILE`, and none of that may pass. -/// `CLAUDE_CONFIG_DIR` stays because it picks which Claude login the keychain -/// holds. The flags still keep that folder's settings, CLAUDE.md, and memory out. -pub const KEPT_ENV: [&str; 10] = ["HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "CLAUDE_CONFIG_DIR"]; - -/// Set for the agent's process. `--setting-sources project` leaves out user -/// settings but not memory: without these the person's `~/.claude/CLAUDE.md` -/// (and every file it imports) and auto-memory still reach the agent. -pub const NO_MEMORY_ENV: [(&str, &str); 2] = [("CLAUDE_CODE_DISABLE_CLAUDE_MDS", "1"), ("CLAUDE_CODE_DISABLE_AUTO_MEMORY", "1")]; - -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ClaudeSettings { - #[serde(default)] - env: HashMap, - api_key_helper: Option, -} - -/// How the agent reaches Claude, read from `~/.claude/settings.json`. -#[derive(Debug, Default)] -pub struct ClaudeInference { - /// Environment for the agent's process. - pub env: Vec<(String, String)>, - /// A command that prints a key, passed with `--settings`. It names a program, not a secret. - pub api_key_helper: Option, -} - -impl ClaudeInference { - /// The inference keys come from the settings, then from the app's own environment. - /// With none of them the agent uses the normal keychain login. - pub fn read(home: &Path) -> Result { - let path = std::env::var_os("CLAUDE_CONFIG_DIR").map_or_else(|| home.join(".claude"), PathBuf::from).join("settings.json"); - let settings: ClaudeSettings = match std::fs::read_to_string(&path) { - // Only the position: an error's own text can quote a value, and a value can be a token. - Ok(text) => serde_json::from_str(&text) - .map_err(|e| format!("{} is not valid settings JSON (line {}, column {})", path.display(), e.line(), e.column()))?, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => ClaudeSettings::default(), - Err(e) => return Err(format!("{}: {e}", path.display())), - }; - let mut inference = Self::from_settings(settings); - for key in CLAUDE_INFERENCE_KEYS { - if !inference.env.iter().any(|(k, _)| k == key) { - if let Some(value) = std::env::var_os(key).and_then(|v| v.into_string().ok()) { - inference.env.push((key.to_owned(), value)); - } - } - } - Ok(inference) - } - - fn from_settings(mut settings: ClaudeSettings) -> Self { - let env = CLAUDE_INFERENCE_KEYS - .iter() - .filter_map(|&key| { - let value = settings.env.remove(key)?; - Some((key.to_owned(), value.as_str().map_or_else(|| value.to_string(), str::to_owned))) - }) - .collect(); - Self { env, api_key_helper: settings.api_key_helper } - } - - /// The Claude Code arguments that leave out the person's setup and add the app's plugin. - pub fn args(&self, plugin: &Path) -> Vec { - let mut args = vec![ - // Settings from the project folder only. User settings, hooks, and plugins stay out. - "--setting-sources".to_owned(), - "project".to_owned(), - // The app's own MCP servers, none yet. The person's servers stay out. - "--strict-mcp-config".to_owned(), - "--mcp-config".to_owned(), - r#"{"mcpServers":{}}"#.to_owned(), - "--plugin-dir".to_owned(), - plugin.display().to_string(), - ]; - if let Some(helper) = &self.api_key_helper { - args.push("--settings".to_owned()); - args.push(serde_json::json!({ "apiKeyHelper": helper }).to_string()); - } - args - } -} - -/// The provider keys Astra's config keeps. Anything else, such as an inline -/// bearer token or header, stays out, so no secret is written. The provider -/// reads its key with `auth.command` or from the `env_key` variable instead. -const PROVIDER_KEYS: [&str; 9] = [ - "name", - "base_url", - "wire_api", - "env_key", - "env_http_headers", - "request_max_retries", - "stream_max_retries", - "stream_idle_timeout_ms", - "auth", -]; -const PROVIDER_AUTH_KEYS: [&str; 5] = ["command", "args", "cwd", "timeout_ms", "refresh_interval_ms"]; - -/// Writes Astra's own `CODEX_HOME` and returns it. Its `config.toml` holds only -/// the model and the cliproxy provider from `~/.codex/config.toml`, reads no -/// AGENTS.md, and turns off every skill under `~/.agents/skills`, which codex -/// would otherwise load. With no cliproxy provider the home has no way to reach -/// a model, so Astra fails and says so instead of running on the person's setup. -pub fn astra_home(home: &Path, app_data: &Path) -> Result { - let user_config = home.join(".codex").join("config.toml"); - // Astra runs only when the agent asks for it, so a codex config it cannot read - // never stops a turn. Astra then has no model and says so when it is called. - let user: toml::Table = match std::fs::read_to_string(&user_config) { - // Only the position: an error's own text quotes the line, and a line can hold a token. - Ok(text) => text.parse().unwrap_or_else(|e: toml::de::Error| { - let at = e.span().map_or(String::new(), |span| format!(" at byte {}", span.start)); - log::warn!("studio: {} is not valid TOML{at}, so Astra cannot reach a model", user_config.display()); - toml::Table::new() - }), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => toml::Table::new(), - Err(e) => { - log::warn!("studio: cannot read {}: {e}, so Astra cannot reach a model", user_config.display()); - toml::Table::new() - } - }; - let AstraConfig { config, secrets } = astra_config(&user, &host_skills(home), has_value); - if !config.contains_key("model_providers") { - log::warn!("studio: ~/.codex/config.toml has no cliproxy provider, so Astra cannot reach a model"); - } - let env = provider_env_names(&config); - let dir = app_data.join("astra"); - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - remove_abandoned(&dir); - // Written whole and then renamed, so an Astra starting meanwhile reads the old file or the new one. - let staged = dir.join(format!("config.toml.{}", uuid::Uuid::new_v4())); - let text = toml::to_string(&config).map_err(|e| format!("Astra config: {e}"))?; - if let Err(e) = write_private(&staged, &text).and_then(|()| std::fs::rename(&staged, dir.join("config.toml"))) { - let _ = std::fs::remove_file(&staged); - return Err(format!("{}: {e}", dir.display())); - } - Ok(AstraHome { dir, env, secrets }) -} - -/// Removes staged configs a stopped app left before it could rename them. One -/// a minute old or more is not another turn's write in progress. -fn remove_abandoned(dir: &Path) { - let Ok(entries) = std::fs::read_dir(dir) else { return }; - let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(60); - for entry in entries.filter_map(Result::ok) { - let staged = entry.file_name().to_string_lossy().starts_with("config.toml."); - let old = entry.metadata().and_then(|m| m.modified()).is_ok_and(|modified| modified < cutoff); - if staged && old { - let _ = std::fs::remove_file(entry.path()); - } - } -} - -/// Astra's home, and the variables its provider reads its key from, which the -/// agent's process keeps when the app has them. -pub struct AstraHome { - pub dir: PathBuf, - /// Variables the provider reads, passed from the app's own environment when it has them. - pub env: Vec, - /// Inline credentials from the person's config, passed by value and never written. - pub secrets: Vec<(String, String)>, -} - -/// The copied provider can name a key command's arguments, so the file is the -/// person's alone, like the config it came from. -fn write_private(path: &Path, text: &str) -> std::io::Result<()> { - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - std::io::Write::write_all(&mut options.open(path)?, text.as_bytes()) -} - -/// The variable names the provider reads: `env_key` and the values of `env_http_headers`. -fn provider_env_names(config: &toml::Table) -> Vec { - let Some(provider) = config.get("model_providers").and_then(|p| p.get("cliproxy")) else { return Vec::new() }; - let key = provider.get("env_key").and_then(toml::Value::as_str).map(str::to_owned); - let headers = provider.get("env_http_headers").and_then(toml::Value::as_table).into_iter().flat_map(|table| table.values().filter_map(toml::Value::as_str).map(str::to_owned)); - key.into_iter().chain(headers).filter(|name| !name.starts_with("M3D_ASTRA_")).collect() -} - -/// Astra's config, and the values of any inline credentials the provider had. -/// A credential written into the person's config (`experimental_bearer_token`, -/// or `http_headers`) is never copied. The new config names an environment -/// variable for it, and the value reaches Astra only through the process. -struct AstraConfig { - config: toml::Table, - secrets: Vec<(String, String)>, -} - -/// `env_set` says whether a variable is set in the app's environment, which -/// decides whether an environment-backed header wins over a static one. -fn astra_config(user: &toml::Table, skills: &[PathBuf], env_set: impl Fn(&str) -> bool) -> AstraConfig { - let keep = |table: &toml::Table, keys: &[&str]| -> toml::Table { - table.iter().filter(|(key, _)| keys.contains(&key.as_str())).map(|(key, value)| (key.clone(), value.clone())).collect() - }; - let mut config = toml::Table::new(); - let mut secrets = Vec::new(); - if let Some(model) = user.get("model") { - config.insert("model".to_owned(), model.clone()); - } - // No AGENTS.md from the project folder or any folder above it. - config.insert("project_doc_max_bytes".to_owned(), 0.into()); - if let Some(provider) = user.get("model_providers").and_then(|p| p.get("cliproxy")).and_then(toml::Value::as_table) { - let source = provider; - let mut provider = keep(source, &PROVIDER_KEYS); - if let Some(auth) = provider.get("auth").and_then(toml::Value::as_table) { - let auth = keep(auth, &PROVIDER_AUTH_KEYS); - provider.insert("auth".to_owned(), auth.into()); - } - if let Some(token) = source.get("experimental_bearer_token").and_then(toml::Value::as_str) { - provider.insert("env_key".to_owned(), ASTRA_BEARER_VAR.into()); - secrets.push((ASTRA_BEARER_VAR.to_owned(), token.to_owned())); - } - if let Some(headers) = source.get("http_headers").and_then(toml::Value::as_table) { - let mut named = provider.get("env_http_headers").and_then(toml::Value::as_table).cloned().unwrap_or_default(); - for (index, (header, value)) in headers.iter().enumerate() { - let Some(value) = value.as_str() else { continue }; - // Codex sends an environment-backed header over a static one when its variable is set, so that mapping stays. - if named.get(header).and_then(toml::Value::as_str).is_some_and(|var| env_set(var)) { - continue; - } - let var = format!("{ASTRA_HEADER_VAR}{index}"); - named.insert(header.clone(), var.clone().into()); - secrets.push((var, value.to_owned())); - } - provider.insert("env_http_headers".to_owned(), named.into()); - } - config.insert("model_provider".to_owned(), "cliproxy".into()); - config.insert("model_providers".to_owned(), toml::Table::from_iter([("cliproxy".to_owned(), provider.into())]).into()); - } - let off: Vec = skills - .iter() - .map(|path| toml::Table::from_iter([("path".to_owned(), path.display().to_string().into()), ("enabled".to_owned(), false.into())]).into()) - .collect(); - config.insert("skills".to_owned(), toml::Table::from_iter([("config".to_owned(), toml::Value::Array(off))]).into()); - AstraConfig { config, secrets } -} - -/// A variable counts only with a value: Codex falls back to a static header -/// when the environment one is empty or blank, so the app does too. -fn has_value(var: &str) -> bool { - std::env::var(var).is_ok_and(|value| !value.trim().is_empty()) -} - -/// The variables that carry an inline provider credential to Astra's process. -const ASTRA_BEARER_VAR: &str = "M3D_ASTRA_BEARER_TOKEN"; -const ASTRA_HEADER_VAR: &str = "M3D_ASTRA_HEADER_"; - -/// Every `SKILL.md` under `~/.agents/skills`, at any depth, as codex finds them. -fn host_skills(home: &Path) -> Vec { - walkdir::WalkDir::new(home.join(".agents").join("skills")) - .follow_links(true) - .into_iter() - .filter_map(Result::ok) - .filter(|entry| entry.file_type().is_file() && entry.file_name() == "SKILL.md") - .map(walkdir::DirEntry::into_path) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_the_inference_keys_and_key_helper_leave_the_claude_settings() { - let settings: ClaudeSettings = serde_json::from_str( - r#"{"env":{"ANTHROPIC_BASE_URL":"https://proxy","ANTHROPIC_AUTH_TOKEN":"t","API_TIMEOUT_MS":60000,"LIFEOS_DIR":"/x"}, - "apiKeyHelper":"/usr/local/bin/key","hooks":{"Stop":[]}}"#, - ) - .expect("settings"); - let inference = ClaudeInference::from_settings(settings); - assert_eq!( - inference.env, - [("ANTHROPIC_BASE_URL", "https://proxy"), ("ANTHROPIC_AUTH_TOKEN", "t"), ("API_TIMEOUT_MS", "60000")].map(|(k, v)| (k.to_owned(), v.to_owned())) - ); - let args = inference.args(Path::new("/app/studio-plugin")); - assert!(args.windows(2).any(|w| w == ["--setting-sources", "project"])); - assert!(args.windows(2).any(|w| w == ["--plugin-dir", "/app/studio-plugin"])); - assert!(args.contains(&r#"{"apiKeyHelper":"/usr/local/bin/key"}"#.to_owned())); - } - - #[test] - fn an_environment_backed_header_keeps_winning_when_its_variable_is_set() { - let user: toml::Table = r#" - [model_providers.cliproxy] - base_url = "https://mini:8317/v1" - http_headers = { Authorization = "Bearer static" } - env_http_headers = { Authorization = "PROXY_AUTH" } - "# - .parse() - .expect("toml"); - let set = astra_config(&user, &[], |var| var == "PROXY_AUTH"); - assert_eq!(set.config["model_providers"]["cliproxy"]["env_http_headers"]["Authorization"].as_str(), Some("PROXY_AUTH")); - assert!(set.secrets.is_empty(), "the static value is not used when the variable is set"); - let unset = astra_config(&user, &[], |_| false); - let var = unset.config["model_providers"]["cliproxy"]["env_http_headers"]["Authorization"].as_str().expect("var").to_owned(); - assert_eq!(unset.secrets, [(var, "Bearer static".to_owned())], "without the variable, the static value is sent"); - } - - #[test] - fn a_blank_environment_header_does_not_hide_the_static_one() { - let var = format!("M3D_TEST_HEADER_{}", std::process::id()); - // SAFETY: a variable no other test reads. - unsafe { std::env::set_var(&var, " ") }; - assert!(!has_value(&var), "a blank value counts as unset"); - unsafe { std::env::set_var(&var, "Bearer env") }; - assert!(has_value(&var)); - unsafe { std::env::remove_var(&var) }; - assert!(!has_value(&var)); - } - - #[test] - fn a_codex_config_astra_cannot_read_never_stops_a_turn() { - let home = tempfile::tempdir().expect("home"); - let data = tempfile::tempdir().expect("data"); - std::fs::create_dir_all(home.path().join(".codex")).expect("codex dir"); - std::fs::write(home.path().join(".codex/config.toml"), "model = \"unterminated").expect("config"); - let astra = astra_home(home.path(), data.path()).expect("the turn goes on"); - let config: toml::Table = std::fs::read_to_string(astra.dir.join("config.toml")).expect("written").parse().expect("toml"); - assert!(config.get("model_providers").is_none(), "Astra has no model to reach"); - } - - #[test] - fn astra_keeps_the_model_and_provider_and_turns_off_host_skills() { - let user: toml::Table = r#" - model = "gpt-6.1-sol" - model_provider = "cliproxy" - [mcp_servers.lifeos] - command = "lifeos" - [model_providers.cliproxy] - base_url = "https://mini:8317/v1" - wire_api = "responses" - experimental_bearer_token = "secret-token" - http_headers = { Authorization = "Bearer secret-token" } - [model_providers.cliproxy.auth] - command = "cat" - args = ["/home/o/.config/codex/cliproxy-api-key"] - "# - .parse() - .expect("toml"); - let AstraConfig { config, secrets } = astra_config(&user, &[PathBuf::from("/home/o/.agents/skills/a/SKILL.md")], |_| false); - assert_eq!(config["model"].as_str(), Some("gpt-6.1-sol")); - assert_eq!(config["model_providers"]["cliproxy"]["auth"]["command"].as_str(), Some("cat")); - assert!(!toml::to_string(&config).expect("toml").contains("secret-token"), "no inline secret is copied"); - // The credentials still reach Astra, by value through its process. - let cliproxy = &config["model_providers"]["cliproxy"]; - let bearer = cliproxy["env_key"].as_str().expect("env_key"); - let header = cliproxy["env_http_headers"]["Authorization"].as_str().expect("header var"); - assert!(secrets.contains(&(bearer.to_owned(), "secret-token".to_owned()))); - assert!(secrets.contains(&(header.to_owned(), "Bearer secret-token".to_owned()))); - assert!(config.get("mcp_servers").is_none(), "the person's MCP servers stay out"); - assert_eq!(config["project_doc_max_bytes"].as_integer(), Some(0)); - assert_eq!(config["skills"]["config"][0]["enabled"].as_bool(), Some(false)); - let no_proxy = astra_config(&"model = \"gpt\"".parse().expect("toml"), &[], |_| false).config; - assert!(no_proxy.get("model_providers").is_none(), "with no cliproxy provider Astra reaches no model"); - } -} diff --git a/src-tauri/src/studio/mod.rs b/src-tauri/src/studio/mod.rs index 4df9b614..f8f8ed83 100644 --- a/src-tauri/src/studio/mod.rs +++ b/src-tauri/src/studio/mod.rs @@ -1,47 +1,18 @@ -//! The chat studio: one project folder per chat, a Claude Code agent that works -//! in it, and the files it writes. -//! -//! The agent is the local `claude` CLI, so it runs on the person's own login and -//! carries their skills, tools, and settings. Each turn is one `claude -p` run -//! in the project folder; later turns resume the same session. +//! The chat studio's native edges: the project folders the agent works in, the +//! files it writes, the preview's navigation guard, and the slice step. The +//! chats themselves live in the sidecar's store (`crate::sidecar`). pub mod files; -mod lean; pub mod slice; -pub mod turn; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; +use std::path::PathBuf; pub use files::register_protocol; -pub use turn::Turns; -/// Hidden folder in each project for the app's own records. +/// Hidden folder in each project for the app's own records: converted photos, and a chat from before the store. +/// The preview and the file list never show it. const META_DIR: &str = ".m3d"; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProjectMeta { - title: String, - created_ms: i64, - /// The Claude Code session every turn after the first resumes. - session_id: String, - /// The session was started by the lean agent. A session from before carries - /// the person's coding setup in its history, so it is never resumed. - #[serde(default)] - lean: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Project { - id: String, - title: String, - created_ms: i64, - path: PathBuf, -} - /// Whether the main window, or a preview frame in it, may go to `url`. The /// app's own pages and project files may load. The internet may not, so a /// preview page cannot carry project data out by navigating its frame there. @@ -78,100 +49,10 @@ pub(crate) fn project_dir(id: &str) -> Result { Ok(projects_root()?.join(id)) } -fn read_meta(dir: &Path) -> Result { - let path = dir.join(META_DIR).join("project.json"); - let text = std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?; - serde_json::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display())) -} - -fn write_meta(dir: &Path, meta: &ProjectMeta) -> Result<(), String> { - let text = serde_json::to_string_pretty(meta).map_err(|e| e.to_string())?; - std::fs::write(dir.join(META_DIR).join("project.json"), text).map_err(|e| format!("writing project record: {e}")) -} - -/// A project from before the lean agent gets a new session, so its next turn -/// starts without the person's setup. Its transcript and files stay. -fn lean_session(dir: &Path, mut meta: ProjectMeta) -> Result { - if meta.lean { - return Ok(meta); - } - meta.session_id = uuid::Uuid::new_v4().to_string(); - meta.lean = true; - match std::fs::remove_file(dir.join(META_DIR).join("session-started")) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("resetting the session: {e}")), - } - write_meta(dir, &meta)?; - Ok(meta) -} - -fn slug(title: &str) -> String { - let mut out = String::new(); - for c in title.chars() { - if c.is_ascii_alphanumeric() { - out.push(c.to_ascii_lowercase()); - } else if !out.ends_with('-') && !out.is_empty() { - out.push('-'); - } - if out.len() >= 40 { - break; - } - } - let out = out.trim_end_matches('-').to_owned(); - if out.is_empty() { "project".to_owned() } else { out } -} - -/// Every project, newest first. A folder without the app's record is not a project. -#[tauri::command] -pub fn studio_projects() -> Result, String> { - let root = projects_root()?; - let entries = match std::fs::read_dir(&root) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(format!("reading {}: {e}", root.display())), - }; - let mut projects: Vec = entries - .filter_map(Result::ok) - .filter_map(|entry| { - let path = entry.path(); - let meta = read_meta(&path).ok()?; - Some(Project { id: entry.file_name().to_string_lossy().into_owned(), title: meta.title, created_ms: meta.created_ms, path }) - }) - .collect(); - projects.sort_by(|a, b| b.created_ms.cmp(&a.created_ms)); - Ok(projects) -} - -/// Makes a new project folder named after the first request. -#[tauri::command] -pub fn studio_create_project(title: String) -> Result { - let root = projects_root()?; - std::fs::create_dir_all(&root).map_err(|e| format!("creating {}: {e}", root.display()))?; - let now = chrono::Local::now(); - let base = format!("{}-{}", now.format("%Y-%m-%d"), slug(&title)); - let (id, path) = (1..) - .map(|n| if n == 1 { base.clone() } else { format!("{base}-{n}") }) - .map(|id| (id.clone(), root.join(id))) - .find(|(_, path)| !path.exists()) - .expect("an unused folder name"); - std::fs::create_dir_all(path.join(META_DIR)).map_err(|e| format!("creating {}: {e}", path.display()))?; - let title: String = title.lines().next().unwrap_or_default().chars().take(80).collect(); - let meta = ProjectMeta { title: title.clone(), created_ms: now.timestamp_millis(), session_id: uuid::Uuid::new_v4().to_string(), lean: true }; - write_meta(&path, &meta)?; - Ok(Project { id, title, created_ms: meta.created_ms, path }) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn slug_keeps_words_and_drops_the_rest() { - assert_eq!(slug("A wall mount for my UGREEN dock!"), "a-wall-mount-for-my-ugreen-dock"); - assert_eq!(slug(" "), "project"); - } - #[test] fn the_window_stays_on_the_app_and_project_files() { let may = |url: &str| may_navigate(&url.parse().expect("url")); @@ -182,19 +63,6 @@ mod tests { assert!(!may("http://192.168.1.5/")); } - #[test] - fn a_project_from_before_the_lean_agent_starts_a_new_session_once() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(dir.path().join(META_DIR)).expect("meta dir"); - std::fs::write(dir.path().join(META_DIR).join("session-started"), b"").expect("started"); - let old: ProjectMeta = serde_json::from_str(r#"{"title":"Clip","createdMs":1,"sessionId":"old"}"#).expect("old record"); - let meta = lean_session(dir.path(), old).expect("lean"); - assert_ne!(meta.session_id, "old"); - assert!(!dir.path().join(META_DIR).join("session-started").exists(), "the next turn starts its session instead of resuming"); - let again = lean_session(dir.path(), read_meta(dir.path()).expect("read")).expect("lean"); - assert_eq!(again.session_id, meta.session_id, "a lean session is kept"); - } - #[test] fn project_ids_are_plain_folder_names() { assert!(project_dir("../etc").is_err()); diff --git a/src-tauri/src/studio/system_prompt.md b/src-tauri/src/studio/system_prompt.md deleted file mode 100644 index 4d9586e6..00000000 --- a/src-tauri/src/studio/system_prompt.md +++ /dev/null @@ -1,22 +0,0 @@ -# Materialize 3D - -You are working inside Materialize 3D, a desktop app. Ossie talks to you here to design parts he prints on his Bambu Lab P2S. The end of every job is a print-ready 3MF file. Everything else supports that. - -Your working directory is this chat's project folder. The app shows Ossie every file you write here: - -- An HTML file renders as a page in the app's preview panel, scripts included. Inline your scripts (for example three.js) or put them beside the page with relative paths. The preview blocks the internet, so a page loads only files in this folder and code inlined in it. -- An STL, 3MF, GLB, or glTF file opens in a 3D orbit viewer. -- PNG, JPG, and SVG files show as pictures. -- The newest file opens on its own, so write the file you want Ossie to look at last. - -Pictures Ossie drops into the chat are saved in `refs/` and attached to his message. Use them as references for shape and measurements. - -## How a job goes - -Follow the `materialize-studio:fabricate` skill for every job. It ships with this app and holds the steps: parameters, model and fit checks, preview, HTML guide, and the slice to 3MF, plus how to hand a task to Astra, the GPT helper. - -## Rules - -- Do not use the old Materialize MCP server or its build, approve, or export tools. Work with files in this folder. -- Keep your messages short and plain. Lead with what changed and what Ossie should look at. -- Never invent measurements for parts Ossie owns. Estimate, mark it TBD, and say so. diff --git a/src-tauri/src/studio/turn.rs b/src-tauri/src/studio/turn.rs deleted file mode 100644 index ef9527bd..00000000 --- a/src-tauri/src/studio/turn.rs +++ /dev/null @@ -1,830 +0,0 @@ -//! One chat turn: a `claude -p` run in the project folder whose stream-json -//! events go to the window and into the project's transcript. - -use std::collections::{BTreeSet, HashMap}; -use std::io::Write as _; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::{Arc, Mutex, OnceLock}; - -use base64::Engine as _; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Manager, State}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio_util::sync::CancellationToken; - -use super::{files, lean, lean_session, project_dir, read_meta, META_DIR}; -use crate::fabrication::build_gate::{BuildGate, OwnedRunning}; -use crate::state::AppState; - -const SYSTEM_PROMPT: &str = include_str!("system_prompt.md"); -const MODEL: &str = "claude-opus-5-5"; -pub const EVENT: &str = "studio://event"; - -/// The variable each agent runs with, which its tools inherit. A reopen after a -/// crash finds what a turn left by this tag, because the agent that started the -/// tools is gone by then. -const TAG_VAR: &str = "M3D_TURN"; - -/// A running turn: Stop cancels it, and quitting the app ends its processes. -struct Turn { - token: CancellationToken, - tag: String, - /// The agent's pid while it is unreaped, 0 before it starts and after. The - /// turn takes this lock to clear it before reaping, and quitting holds it - /// while ending the turn, so the pid is never used after it could be reused. - leader: Arc>, -} - -/// The running turn of each project, so a second send waits and Stop can end it. -#[derive(Default)] -pub struct Turns(Mutex>); - -impl Turns { - /// Ends every running agent and its tools. Called as the app exits, when - /// there is no time left to wait on the turns' own tasks. - pub fn stop_all(&self) { - let Ok(running) = self.0.lock() else { return }; - let tags: Vec = running.values().inspect(|turn| turn.token.cancel()).map(|turn| turn.tag.clone()).collect(); - // Held until the end, so no turn reaps its agent while its pid is in use here. - let leaders: Vec<_> = running.values().filter_map(|turn| turn.leader.lock().ok()).collect(); - let pids: Vec = leaders.iter().map(|leader| **leader).collect(); - end_turn(&pids, &tags, &BTreeSet::new()); - } - - /// The groups of every running agent but this project's, which this turn must never signal. - fn other_agents(&self, project: &str) -> BTreeSet { - let Ok(running) = self.0.lock() else { return BTreeSet::new() }; - running - .iter() - .filter(|(name, _)| name.as_str() != project) - .filter_map(|(_, turn)| turn.leader.lock().ok().map(|pid| *pid as i32)) - .filter(|&group| group > 0) - .collect() - } -} - -/// One process in a `ps` reading. -#[cfg(unix)] -struct Process { - pid: i32, - parent: i32, - group: i32, - /// False for a process that has exited but is not yet reaped (a zombie). - running: bool, -} - -/// Every process, with its parent and group. `ps` shows these for every process, -/// the system's own programs included. -#[cfg(unix)] -fn processes() -> Vec { - let Ok(out) = std::process::Command::new("ps").args(["-axo", "pid=,ppid=,pgid=,stat="]).output() else { return Vec::new() }; - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(|line| { - let mut words = line.split_whitespace(); - Some(Process { - pid: words.next()?.parse().ok()?, - parent: words.next()?.parse().ok()?, - group: words.next()?.parse().ok()?, - running: !words.next()?.starts_with('Z'), - }) - }) - .collect() -} - -/// The process groups that hold a process carrying one of these turn tags. -/// macOS hides the environment of its own programs (sh, bash, zsh, sleep, tail), -/// so there only tools such as python, uv, node, and Bambu Studio are found this way. -#[cfg(unix)] -fn tagged_groups(tags: &[String]) -> Vec { - if tags.is_empty() { - return Vec::new(); - } - let args: &[&str] = if cfg!(target_os = "macos") { &["-axE", "-ww", "-o", "pgid=,command="] } else { &["axeww", "-o", "pgid=,command="] }; - let Ok(out) = std::process::Command::new("ps").args(args).output() else { return Vec::new() }; - let marks: Vec = tags.iter().map(|tag| format!("{TAG_VAR}={tag}")).collect(); - String::from_utf8_lossy(&out.stdout) - .lines() - // A whole word, as `ps` prints each variable, not any text that contains it. - .filter(|line| line.split_whitespace().any(|word| marks.iter().any(|mark| word == mark))) - .filter_map(|line| line.split_whitespace().next()?.parse().ok()) - .collect() -} - -/// The groups of these agents and of every process under them. This covers a -/// shell tool that Claude Code started in a group of its own, whose environment -/// macOS hides. Only safe while each leader is unreaped. -#[cfg(unix)] -fn tree_groups(table: &[Process], leaders: &[u32]) -> BTreeSet { - let mut tree: BTreeSet = leaders.iter().filter(|&&pid| pid > 0).map(|&pid| pid as i32).collect(); - loop { - let more: Vec = table.iter().filter(|p| tree.contains(&p.parent) && !tree.contains(&p.pid)).map(|p| p.pid).collect(); - if more.is_empty() { - break; - } - tree.extend(more); - } - table.iter().filter(|p| tree.contains(&p.pid)).map(|p| p.group).collect() -} - -/// Ends a turn's processes: SIGTERM to every group it owns, then SIGKILL after -/// three seconds to whatever still runs. A group is the turn's when it is in the -/// agent's live tree or holds a process with the turn's tag, and never when it is -/// the app's group or in `exclude` (other turns' agents). The tree is read before -/// any signal, while every leader is unreaped, so a tool that loses its agent -/// stays on the list. Blocking: for Stop, the end of every turn, the app's exit, -/// and a reopen after a crash. -/// -/// Known limits (issue #192), because macOS gives an app no way to own a process tree: a tool -/// Claude Code leaves running when it exits by itself, and a tool that survives a -/// crash of both the app and the agent, are found only by their tag, and macOS -/// hides the tag of its own programs (sh, bash, zsh, sleep, tail). -fn end_turn(leaders: &[u32], tags: &[String], exclude: &BTreeSet) { - #[cfg(unix)] - { - // SAFETY: getpgrp only reads this process's own group. - let own = unsafe { libc::getpgrp() }; - let ours = |group: &i32| *group > 1 && *group != own && !exclude.contains(group); - let read = |table: &[Process]| -> BTreeSet { tree_groups(table, leaders).into_iter().chain(tagged_groups(tags)).filter(ours).collect() }; - // A group that is already gone is the outcome we want, so the result is not read. - let signal = |groups: &mut dyn Iterator, sig: libc::c_int| { - for group in groups { - // SAFETY: killpg only signals a group this turn owns. - let _ = unsafe { libc::killpg(group, sig) }; - } - }; - let table = processes(); - if table.is_empty() { - // No `ps` to read: end at least each agent's own group, whose pid is still its own. - signal(&mut leaders.iter().map(|&pid| pid as i32).filter(ours), libc::SIGTERM); - return; - } - let mut known = read(&table); - if known.is_empty() { - return; - } - signal(&mut known.iter().copied(), libc::SIGTERM); - for _ in 0..30 { - let table = processes(); - let fresh = read(&table); - signal(&mut fresh.difference(&known).copied(), libc::SIGTERM); - known.extend(fresh); - if !table.iter().any(|p| p.running && known.contains(&p.group)) { - return; - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - let table = processes(); - signal(&mut known.iter().copied().filter(|group| table.iter().any(|p| p.running && p.group == *group)), libc::SIGKILL); - } -} - -/// Waits up to five seconds for an agent whose output ended to exit by itself, -/// without reaping it, so its pid stays its own while the turn is ended. -#[cfg(unix)] -fn wait_for_exit(pid: u32) { - for _ in 0..50 { - if !processes().iter().any(|p| p.pid == pid as i32 && p.running) { - return; - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } -} - -/// Names the running turn's tag while it runs, so a reopen after a crash can find what it left. -fn tag_path(dir: &Path) -> PathBuf { - dir.join(META_DIR).join("agent.turn") -} - -/// After a crash, an agent or its tools can outlive the app. The tag file names -/// the turn, and every process still carrying that tag is stopped. The agent is -/// gone by then, so its process tree cannot be walked. Known limit: a tool that -/// clears its environment, or on macOS a group made only of the system's own -/// programs (sh, bash, zsh, sleep), shows no tag and survives a crash of both -/// the app and the agent. Stop, the end of a turn, and quitting the app read the -/// live tree instead, so they end those tools. -fn stop_survivor(dir: &Path) { - let path = tag_path(dir); - // Only a whole tag counts. Anything else could match other turns' processes. - let tag = std::fs::read_to_string(&path).ok().and_then(|text| uuid::Uuid::parse_str(text.trim()).ok()); - if let Some(tag) = tag { - log::warn!("studio: stopping processes left from a turn before the app closed"); - end_turn(&[], &[tag.to_string()], &BTreeSet::new()); - } - let _ = std::fs::remove_file(&path); -} - -fn transcript_path(dir: &Path) -> PathBuf { - dir.join(META_DIR).join("transcript.jsonl") -} - -/// Reads the transcript. A last line without its newline is a write still in -/// progress, or one the app never finished because it stopped. Readers skip it. -/// The one writer (`repair`) cuts it off: the turn that owns the file, or the -/// reconcile when no turn runs. -fn read_transcript(dir: &Path, repair: bool) -> Result, String> { - let path = transcript_path(dir); - // Bytes first: a write cut inside a multibyte character is not valid UTF-8 until it is cut off. - let mut bytes = match std::fs::read(&path) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(format!("{}: {e}", path.display())), - }; - if bytes.last().is_some_and(|&b| b != b'\n') { - bytes.truncate(bytes.iter().rposition(|&b| b == b'\n').map_or(0, |end| end + 1)); - if repair { - std::fs::write(&path, &bytes).map_err(|e| format!("{}: {e}", path.display()))?; - } - } - let text = String::from_utf8(bytes).map_err(|e| format!("{}: {e}", path.display()))?; - text.lines().map(|line| serde_json::from_str(line).map_err(|e| format!("{}: {e}", path.display()))).collect() -} - -/// The project's transcript, one JSON event per line. A line's index is its -/// `seq`, which lets the window join a loaded history with live events without -/// a gap or a repeat. -struct Transcript { - dir: PathBuf, - next: usize, -} - -impl Transcript { - fn open(dir: &Path) -> Result { - Ok(Self { dir: dir.to_owned(), next: read_transcript(dir, true)?.len() }) - } - - fn write(&mut self, event: &Value) -> Result { - let path = transcript_path(&self.dir); - let mut file = std::fs::OpenOptions::new().create(true).append(true).open(&path).map_err(|e| format!("{}: {e}", path.display()))?; - writeln!(file, "{event}").map_err(|e| format!("{}: {e}", path.display()))?; - self.next += 1; - Ok(self.next - 1) - } - - /// Writes the event, then sends it to the window with its `seq`. - fn record(&mut self, app: &AppHandle, project: &str, event: Value) -> Result<(), String> { - let seq = self.write(&event)?; - let _ = app.emit(EVENT, json!({ "project": project, "seq": seq, "event": event })); - Ok(()) - } -} - -/// A turn whose user message has no end event was cut off when the app closed. -fn has_open_turn(events: &[Value]) -> bool { - events.iter().fold(false, |open, event| match event["type"].as_str() { - Some("m3d_user") => true, - Some("m3d_turn_end") => false, - _ => open, - }) -} - -/// Everything said in the project so far, in order. A turn the app was closed -/// during is ended here, so the chat never waits on a process that is gone. -#[tauri::command] -pub fn studio_transcript(turns: State<'_, Turns>, project: String) -> Result, String> { - let dir = project_dir(&project)?; - let running = turns.0.lock().map_err(|e| e.to_string())?; - let idle = !running.contains_key(&project); - let mut events = read_transcript(&dir, idle)?; - if idle && has_open_turn(&events) { - stop_survivor(&dir); - let end = json!({ "type": "m3d_turn_end", "ok": false, "error": "This turn stopped when the app closed." }); - Transcript { dir, next: events.len() }.write(&end)?; - events.push(end); - } - Ok(events) -} - -/// The app's data folder, or a verification run's own. -fn app_data_dir(app: &AppHandle) -> Result { - #[cfg(feature = "e2e")] - if let Some(dir) = std::env::var_os("M3D_E2E_DATA_DIR") { - return Ok(PathBuf::from(dir)); - } - app.path().app_data_dir().map_err(|e| format!("app data folder: {e}")) -} - -/// How long the login shell may take to tell its PATH before the app goes on without it. -const LOGIN_SHELL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); - -/// The PATH of the person's login shell. An app opened from Finder starts with -/// only the system folders, where `claude`, `codex`, and `uv` are not. -pub(crate) fn login_path() -> &'static str { - static PATH: OnceLock = OnceLock::new(); - PATH.get_or_init(|| { - let current = std::env::var("PATH").unwrap_or_default(); - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_owned()); - match shell_path(&shell, LOGIN_SHELL_DEADLINE) { - Some(path) if current.is_empty() => path, - Some(path) => format!("{path}:{current}"), - None => current, - } - }) -} - -/// The PATH `shell` reports as a login shell, or none when it fails, says nothing, or does not end within -/// `deadline`. A shell whose startup files hang is ended with all it started, so the sidecar and the runner still -/// start. -fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { - let marker = "__M3D_PATH__"; - let end = std::time::Instant::now() + deadline; - let mut command = std::process::Command::new(shell); - command - .args(["-ilc", &format!("printf '{marker}%s' \"$PATH\"")]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - // The shell leads a session of its own, so the deadline ends what its startup files started too. A session, not - // only a group: a shell in a background group of the terminal the app started from stops before its startup - // files, and one in a session of its own has no terminal to stop for. - #[cfg(unix)] - // SAFETY: setsid is async-signal-safe and only makes the child a session leader before it runs the shell. - unsafe { - std::os::unix::process::CommandExt::pre_exec(&mut command, || { - if libc::setsid() == -1 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - let mut child = command.spawn().ok()?; - let mut stdout = child.stdout.take()?; - let (sent, said) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut out = Vec::new(); - let _ = std::io::Read::read_to_end(&mut stdout, &mut out); - let _ = sent.send(out); - }); - let out = said.recv_timeout(deadline).ok(); - // Startup files can send stdout elsewhere and go on running, so the shell's end has the same deadline. - let ended = loop { - match child.try_wait() { - Ok(Some(_)) => break true, - Ok(None) if std::time::Instant::now() < end => std::thread::sleep(std::time::Duration::from_millis(20)), - _ => break false, - } - }; - if !ended { - #[cfg(unix)] - if let Ok(group) = i32::try_from(child.id()) { - // SAFETY: killpg only sends a signal, to the group this shell leads, as its session leader, and has not been - // reaped from. - unsafe { libc::killpg(group, libc::SIGKILL) }; - } - let _ = child.kill(); - let _ = child.wait(); - } - let out = out?; - String::from_utf8_lossy(&out).rsplit_once(marker).map(|(_, path)| path.trim().to_owned()).filter(|path| !path.is_empty()) -} - -/// The image the model gets for a picture in `refs/`. Claude reads JPEG, PNG, -/// GIF, and WebP up to about 5 MB, so on macOS a HEIC photo or a large one is -/// sent as a smaller JPEG made with `sips`. The original stays in `refs/`. -fn image_block(dir: &Path, rel: &str) -> Result { - let path = dir.join(rel); - let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default(); - let size = std::fs::metadata(&path).map_err(|e| format!("{rel}: {e}"))?.len(); - let readable = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp"); - let send_path = if cfg!(target_os = "macos") && (!readable || size > 3_500_000) { - let out = dir.join(META_DIR).join("send").join(format!("{}.jpg", rel.replace('/', "_"))); - std::fs::create_dir_all(out.parent().expect("has parent")).map_err(|e| e.to_string())?; - let status = std::process::Command::new("sips") - .args(["-s", "format", "jpeg", "-Z", "2000"]) - .arg(&path) - .arg("--out") - .arg(&out) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map_err(|e| format!("sips: {e}"))?; - if !status.success() { - return Err(format!("could not convert {rel} to JPEG")); - } - out - } else if readable { - path - } else { - return Err(format!("{rel} is not a JPEG, PNG, GIF, or WebP picture")); - }; - let media_type = match send_path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { - Some("png") => "image/png", - Some("gif") => "image/gif", - Some("webp") => "image/webp", - _ => "image/jpeg", - }; - let bytes = std::fs::read(&send_path).map_err(|e| format!("{}: {e}", send_path.display()))?; - let data = base64::engine::general_purpose::STANDARD.encode(bytes); - Ok(json!({ "type": "image", "source": { "type": "base64", "media_type": media_type, "data": data } })) -} - -/// Hook chatter is noise in the chat; everything else is kept. -fn keep(event: &Value) -> bool { - !(event["type"] == "system" && event["subtype"] != "init") -} - -/// Starts a turn. Returns once the agent is running; its events arrive on -/// `studio://event` and end with `m3d_turn_end`. -#[tauri::command] -pub fn studio_send(app: AppHandle, turns: State<'_, Turns>, project: String, text: String, images: Vec) -> Result<(), String> { - let dir = project_dir(&project)?; - let meta = lean_session(&dir, read_meta(&dir)?)?; - let mut content: Vec = images.iter().map(|rel| image_block(&dir, rel)).collect::>()?; - let mut prompt = text.clone(); - if !images.is_empty() { - prompt.push_str(&format!("\n\n(Pictures attached, saved in this folder as: {})", images.join(", "))); - } - content.push(json!({ "type": "text", "text": prompt })); - let message = json!({ "type": "user", "message": { "role": "user", "content": content } }); - - // The agent's own environment: the person's inference login, the app's plugin, and nothing else of their setup. - let home = dirs::home_dir().ok_or("no home folder")?; - let inference = lean::ClaudeInference::read(&home)?; - let plugin = app.path().resource_dir().map_err(|e| format!("app resources: {e}"))?.join("studio-plugin"); - let app_data = app_data_dir(&app)?; - - // Taken before anything is written and dropped after the last write, on every path. - let pass = take_pass(app.state::>().inner().clone())?; - let token = CancellationToken::new(); - let tag = uuid::Uuid::new_v4().to_string(); - let leader = Arc::new(Mutex::new(0)); - { - let mut running = turns.0.lock().map_err(|e| e.to_string())?; - if running.contains_key(&project) { - return Err("The agent is still working on the last message.".to_owned()); - } - running.insert(project.clone(), Turn { token: token.clone(), tag: tag.clone(), leader: leader.clone() }); - } - // A turn that cannot write its first line never started, so it gives its place back. - let mut transcript = match Transcript::open(&dir) - .and_then(|mut t| t.record(&app, &project, json!({ "type": "m3d_user", "text": text, "images": images })).map(|()| t)) - { - Ok(transcript) => transcript, - Err(e) => { - turns.0.lock().map_err(|e| e.to_string())?.remove(&project); - return Err(e); - } - }; - - let started = dir.join(META_DIR).join("session-started"); - let session_flag = if started.exists() { "--resume" } else { "--session-id" }; - let model = std::env::var("M3D_AGENT_MODEL").unwrap_or_else(|_| MODEL.to_owned()); - let mut command = tokio::process::Command::new("claude"); - command - .args(["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"]) - .args(["--model", &model, "--permission-mode", "bypassPermissions"]) - .args(["--append-system-prompt", SYSTEM_PROMPT]) - .args([session_flag, &meta.session_id]) - .args(inference.args(&plugin)) - .current_dir(&dir) - // Only what the agent needs: a home, a shell, the locale, the login PATH, - // the inference keys, and Astra's own home. Nothing else of the app's environment. - .env_clear() - .envs(lean::KEPT_ENV.iter().filter_map(|&key| std::env::var_os(key).map(|value| (key, value)))) - .env("PATH", login_path()) - .envs(inference.env.iter().map(|(key, value)| (key, value))) - .envs(lean::NO_MEMORY_ENV) - .env(TAG_VAR, &tag) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - // Its own process group, apart from the app's, so signals to it never reach the app. - #[cfg(unix)] - command.process_group(0); - - tauri::async_runtime::spawn(async move { - let before: HashMap = files::list(&dir).unwrap_or_default().into_iter().map(|f| (f.rel.clone(), f.modified_ms)).collect(); - // Astra's home walks the person's skills folder, so it is made here, off the main thread. - // If it cannot be made, Astra still gets the app's folder and so has no model, never the person's setup. - let astra_dir = app_data.join("astra"); - let astra = tauri::async_runtime::spawn_blocking(move || lean::astra_home(&home, &app_data)) - .await - .map_err(|e| format!("preparing Astra: {e}")) - .and_then(|made| made) - .unwrap_or_else(|error| { - log::warn!("studio: {error}, so Astra cannot reach a model this turn"); - lean::AstraHome { dir: astra_dir, env: Vec::new(), secrets: Vec::new() } - }); - let mut command = command; - // Astra, the codex helper, finds its home through the agent's environment, plus the variables its provider reads. - command - .env("CODEX_HOME", &astra.dir) - .envs(astra.env.iter().filter_map(|key| std::env::var_os(key).map(|value| (key, value)))) - .envs(astra.secrets.iter().map(|(key, value)| (key, value))); - let outcome = run(&app, &project, &mut transcript, &started, command, message, token, &tag, &leader).await; - let changed: Vec = files::list(&dir) - .unwrap_or_default() - .into_iter() - .filter(|f| before.get(&f.rel) != Some(&f.modified_ms) && !f.rel.starts_with("refs/")) - .map(|f| f.rel) - .collect(); - let mut events = Vec::new(); - if !changed.is_empty() { - events.push(json!({ "type": "m3d_files", "files": changed })); - } - events.push(match outcome { - Ok(()) => json!({ "type": "m3d_turn_end", "ok": true }), - Err(error) => json!({ "type": "m3d_turn_end", "ok": false, "error": error }), - }); - for event in events { - if let Err(e) = transcript.record(&app, &project, event) { - log::error!("studio: transcript write failed: {e}"); - } - } - if let Some(turns) = app.try_state::() { - if let Ok(mut running) = turns.0.lock() { - running.remove(&project); - } - } - // The turn's last line is written, so the updater may go ahead. - drop(pass); - }); - Ok(()) -} - -/// The gate inside the app's state, so a turn's pass can own its way to it. -struct StateGate(Arc); - -impl std::ops::Deref for StateGate { - type Target = BuildGate; - fn deref(&self) -> &BuildGate { - &self.0.builds - } -} - -/// A turn's build pass. The updater restarts the app only once no build and no -/// turn holds one. A turn takes it before its first transcript line and drops it -/// after its last, so a restart never cuts a turn's work or its record short. -/// While an update installs, the gate is closed and a new turn is refused. -fn take_pass(state: Arc) -> Result, String> { - BuildGate::enter_owned(StateGate(state)) - .ok_or_else(|| "An update is installing, and the app restarts when it is done. Send this again after it opens.".to_owned()) -} - -async fn run( - app: &AppHandle, - project: &str, - transcript: &mut Transcript, - started: &Path, - mut command: tokio::process::Command, - message: Value, - token: CancellationToken, - tag: &str, - leader: &Mutex, -) -> Result<(), String> { - // Written before the agent starts and removed when this run ends, so a crash leaves it behind. - let tag_file = tag_path(&transcript.dir); - // Written whole or not at all, so a crash never leaves a partial tag. - let staged = tag_file.with_extension("turn.new"); - std::fs::write(&staged, tag).and_then(|()| std::fs::rename(&staged, &tag_file)).map_err(|e| format!("{}: {e}", tag_file.display()))?; - let _tag_file = RemoveOnDrop(tag_file); - let mut child = command.spawn().map_err(|e| format!("could not start the claude CLI: {e}. Install Claude Code and sign in."))?; - let pid = child.id().unwrap_or(0); - *leader.lock().map_err(|e| e.to_string())? = pid; - - let mut stderr = child.stderr.take().expect("piped stderr"); - let stderr_task = tokio::spawn(async move { - let mut text = String::new(); - let _ = stderr.read_to_string(&mut text).await; - text - }); - let outcome = drive(app, project, transcript, started, &mut child, message, &token).await; - - // However the turn ended, nothing of it keeps running, and this happens before - // the tag file goes. The agent is not reaped yet, so its pid is still its own. - let finished = matches!(outcome, Ok(_)); - let exclude = app.try_state::().map(|turns| turns.other_agents(project)).unwrap_or_default(); - let tags = vec![tag.to_owned()]; - let _ = tauri::async_runtime::spawn_blocking(move || { - #[cfg(unix)] - if finished { - wait_for_exit(pid); - } - end_turn(&[pid], &tags, &exclude); - }) - .await; - // Whatever the process reading found, the agent itself does not outlive its - // turn. A kill without a wait leaves it unreaped, so its pid stays its own. - let _ = child.start_kill(); - // Cleared before reaping, under the lock quitting holds while it ends turns, - // so no one uses the pid once it could belong to someone else. - if let Ok(mut pid) = leader.lock() { - *pid = 0; - } - let status = child.wait().await.map_err(|e| format!("waiting for claude: {e}")); - let result_error = outcome?; - let status = status?; - let stderr = stderr_task.await.unwrap_or_default(); - if let Some(error) = result_error { - return Err(error); - } - if !status.success() { - let tail: String = stderr.trim().chars().rev().take(600).collect::>().into_iter().rev().collect(); - return Err(if tail.is_empty() { format!("claude exited with {status}") } else { tail }); - } - Ok(()) -} - -/// Sends the message and records the agent's events until its output ends or -/// it is stopped. Returns the error the agent reported, if any. It never reaps -/// the agent: `run` does that after the turn's processes are ended. -async fn drive( - app: &AppHandle, - project: &str, - transcript: &mut Transcript, - started: &Path, - child: &mut tokio::process::Child, - message: Value, - token: &CancellationToken, -) -> Result, String> { - let mut stdin = child.stdin.take().expect("piped stdin"); - let line = format!("{message}\n"); - // A Stop or a quit that came while the agent was starting ends it here. - tokio::select! { - () = token.cancelled() => return Err("Stopped.".to_owned()), - written = stdin.write_all(line.as_bytes()) => written.map_err(|e| format!("writing to claude: {e}"))?, - } - drop(stdin); - - let mut lines = BufReader::new(child.stdout.take().expect("piped stdout")).lines(); - let mut result_error: Option = None; - loop { - let line = tokio::select! { - () = token.cancelled() => return Err("Stopped.".to_owned()), - line = lines.next_line() => line.map_err(|e| format!("reading claude output: {e}"))?, - }; - let Some(line) = line else { return Ok(result_error) }; - let Ok(event) = serde_json::from_str::(&line) else { continue }; - if event["type"] == "system" && event["subtype"] == "init" { - let _ = std::fs::write(started, b""); - } - if event["type"] == "result" && event["is_error"] == true { - result_error = Some(event["result"].as_str().unwrap_or("The agent stopped with an error.").to_owned()); - } - if keep(&event) { - transcript.record(app, project, event)?; - } - } -} - -struct RemoveOnDrop(PathBuf); - -impl Drop for RemoveOnDrop { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } -} - -/// Stops the project's running turn. -#[tauri::command] -pub fn studio_cancel(turns: State<'_, Turns>, project: String) -> Result<(), String> { - if let Some(turn) = turns.0.lock().map_err(|e| e.to_string())?.get(&project) { - turn.token.cancel(); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(unix)] - #[test] - fn a_login_shell_that_hangs_is_ended_at_its_deadline() { - let dir = tempfile::tempdir().unwrap(); - let shell = dir.path().join("slow-shell"); - std::fs::write(&shell, "#!/bin/sh\nsleep 30\n").unwrap(); - let mut perms = std::fs::metadata(&shell).unwrap().permissions(); - std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); - std::fs::set_permissions(&shell, perms).unwrap(); - let started = std::time::Instant::now(); - assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_millis(300)), None); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); - - // Startup files that send stdout elsewhere and then hang end at the deadline too. - std::fs::write(&shell, "#!/bin/sh\nexec >/dev/null\nsleep 30\n").unwrap(); - let started = std::time::Instant::now(); - assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_millis(300)), None); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); - assert!(shell_path("/bin/sh", std::time::Duration::from_secs(5)).is_some_and(|path| !path.is_empty())); - } - - const SETTLE: std::time::Duration = std::time::Duration::from_millis(50); - - /// The pass `studio_send` takes holds an update off until the turn drops it, - /// and while the update installs a new turn is refused before it writes anything. - #[tokio::test] - async fn an_update_waits_for_a_turns_pass_and_a_turn_is_refused_while_it_installs() { - let state = Arc::new(AppState::default()); - let pass = take_pass(state.clone()).expect("a turn starts"); - let mut close = std::pin::pin!(state.builds.close_when_idle(|_| {})); - assert!(tokio::time::timeout(SETTLE, &mut close).await.is_err(), "the update waits while the turn holds its pass"); - drop(pass); - let closed = tokio::time::timeout(SETTLE, &mut close).await.expect("the update goes ahead once the turn is done"); - let refused = take_pass(state.clone()).err().expect("a turn is refused while the update installs"); - assert!(refused.contains("update is installing")); - drop(closed); - assert!(take_pass(state.clone()).is_ok(), "a failed install lets turns start again"); - } - - #[test] - fn a_reopened_transcript_numbers_lines_on_and_knows_an_unfinished_turn() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(dir.path().join(META_DIR)).expect("meta dir"); - let mut first = Transcript::open(dir.path()).expect("open"); - assert_eq!(first.write(&json!({ "type": "m3d_user", "text": "a clip" })).expect("write"), 0); - assert_eq!(first.write(&json!({ "type": "assistant" })).expect("write"), 1); - - let mut reopened = Transcript::open(dir.path()).expect("reopen"); - assert!(has_open_turn(&read_transcript(dir.path(), false).expect("read"))); - assert_eq!(reopened.write(&json!({ "type": "m3d_turn_end", "ok": true })).expect("write"), 2); - assert!(!has_open_turn(&read_transcript(dir.path(), false).expect("read"))); - } - - #[test] - fn a_write_cut_off_by_a_crash_is_dropped_and_the_next_line_numbers_on() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(dir.path().join(META_DIR)).expect("meta dir"); - std::fs::write(transcript_path(dir.path()), "{\"type\":\"m3d_user\"}\n{\"type\":\"assi").expect("write"); - assert_eq!(read_transcript(dir.path(), false).expect("read").len(), 1); - assert!(std::fs::read_to_string(transcript_path(dir.path())).expect("raw").ends_with("assi"), "a reader leaves a write in progress alone"); - let mut transcript = Transcript::open(dir.path()).expect("open"); - assert_eq!(transcript.write(&json!({ "type": "m3d_turn_end", "ok": false })).expect("write"), 1); - assert_eq!(read_transcript(dir.path(), false).expect("read").len(), 2); - } - - /// The Stop path, with no tag: the agent starts `sleep` in a group of its - /// own, as Claude Code does for each tool. `sleep` is a system program, - /// so on macOS only the walk of the live process tree can find it. - #[cfg(unix)] - #[test] - fn a_shell_tool_in_its_own_group_is_ended_through_the_live_tree() { - use std::os::unix::process::CommandExt as _; - // perl, not `sh -c 'set -m'`: dash ignores job control without a terminal, so it never moves the tool. - let mut agent = std::process::Command::new("perl") - .args(["-e", r#"if (fork == 0) { setpgrp(0, 0); exec "sleep", "30" } wait"#]) - .process_group(0) - .spawn() - .expect("perl"); - let leader = agent.id(); - let tool = (0..50) - .find_map(|_| { - let found = processes().into_iter().find(|p| p.parent == leader as i32 && p.group == p.pid); // forked first, then moved to its own group - if found.is_none() { - std::thread::sleep(std::time::Duration::from_millis(100)); - } - found - }) - .expect("the agent started its tool"); - assert_ne!(tool.group, leader as i32, "the tool runs in a group of its own"); - end_turn(&[leader], &[], &BTreeSet::new()); - let _ = agent.wait(); - // SAFETY: signal 0 only asks whether the process exists. - let gone = (0..30).any(|_| unsafe { libc::kill(tool.pid, 0) } != 0 || { - std::thread::sleep(std::time::Duration::from_millis(100)); - false - }); - assert!(gone, "the tool outside the agent's group was ended"); - } - - #[cfg(unix)] - #[test] - fn after_a_crash_a_tagged_tool_is_ended_and_an_untagged_one_is_not() { - use std::os::unix::process::CommandExt as _; - let tag = uuid::Uuid::new_v4().to_string(); - let spawn = |tagged: bool| { - // python3, not sleep: macOS hides the environment of its own system programs such as sleep. - let mut command = std::process::Command::new("python3"); - command.args(["-c", "import time; time.sleep(30)"]).process_group(0); - if tagged { - command.env(TAG_VAR, &tag); - } - command.spawn().expect("sleep") - }; - let (mut tool, mut other) = (spawn(true), spawn(false)); - // `ps` shows a new process's environment only once it has started, which can lag the spawn. - let seen = (0..50).any(|_| !tagged_groups(std::slice::from_ref(&tag)).is_empty() || { - std::thread::sleep(std::time::Duration::from_millis(100)); - false - }); - assert!(seen, "ps never showed the tagged process"); - end_turn(&[], &[tag.clone()], &BTreeSet::new()); - assert!(tool.wait().expect("wait").code().is_none(), "the tagged tool was killed by a signal"); - assert!(other.try_wait().expect("try_wait").is_none(), "the untagged process still runs"); - other.kill().expect("kill"); - let _ = other.wait(); - } - - #[test] - fn a_write_cut_inside_a_multibyte_character_is_dropped_too() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(dir.path().join(META_DIR)).expect("meta dir"); - let mut bytes = b"{\"type\":\"m3d_user\"}\n{\"text\":\"".to_vec(); - bytes.extend_from_slice(&"\u{8bbe}".as_bytes()[..2]); - std::fs::write(transcript_path(dir.path()), bytes).expect("write"); - assert_eq!(read_transcript(dir.path(), false).expect("read").len(), 1); - assert_eq!(Transcript::open(dir.path()).expect("open").next, 1); - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b081d688..b845b371 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -44,8 +44,7 @@ "resources": { "../LICENSE": "LICENSE", "../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", - "resources/bambu/AGPL-3.0.txt": "AGPL-3.0.txt", - "resources/studio-plugin/": "studio-plugin/" + "resources/bambu/AGPL-3.0.txt": "AGPL-3.0.txt" }, "macOS": { "minimumSystemVersion": "26.0", diff --git a/src/studio/Chat.tsx b/src/studio/Chat.tsx index c0c4b9db..a8e29d2a 100644 --- a/src/studio/Chat.tsx +++ b/src/studio/Chat.tsx @@ -91,8 +91,6 @@ export function ChatView({ project, items, running, onPreview, onError }: ChatVi ))} ) - case 'error': - return

{item.text}

} })} {running &&

Working…

} diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 9cab6440..97d4631f 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -2,15 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -const { invokeMock, listeners } = vi.hoisted(() => ({ invokeMock: vi.fn(), listeners: [] as ((event: { payload: unknown }) => void)[] })) +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })) vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })) -vi.mock('@tauri-apps/api/event', () => ({ - listen: (_name: string, handler: (event: { payload: unknown }) => void) => { - listeners.push(handler) - return Promise.resolve(() => {}) - }, -})) +vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn().mockResolvedValue(() => {}) })) +vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: vi.fn() })) vi.mock('@tauri-apps/api/webview', () => ({ getCurrentWebview: () => ({ onDragDropEvent: vi.fn().mockResolvedValue(() => {}) }), })) @@ -20,68 +16,52 @@ import StudioApp from './StudioApp' // jsdom has no layout, so the chat's scroll to its newest line is a no-op here. Element.prototype.scrollIntoView = vi.fn() -const projectB = { id: '2026-11-06-b', title: 'Project B', createdMs: 1, path: '/p/b' } +const projectA = { id: '2026-11-06-a', conversation: 2, title: 'Project A', createdMs: 2 } +const projectB = { id: '2026-11-06-b', conversation: 3, title: 'Project B', createdMs: 1 } +const view = (project: string, more: object = {}) => ({ project, entries: [], running: false, waiting: null, turn: null, ...more }) +const settings = { model: { provider: 'anthropic', modelId: 'claude-opus-5-5' }, thinking: 'high', printer: {} } afterEach(() => { cleanup() invokeMock.mockReset() - listeners.length = 0 }) -function mockStudio(overrides: Record Promise>) { - invokeMock.mockImplementation((command: string) => { - if (command in overrides) return overrides[command]() - switch (command) { - case 'studio_projects': - return Promise.resolve([projectB]) - case 'studio_transcript': - case 'studio_files': - return Promise.resolve([]) - case 'update_check': - return Promise.resolve({ status: 'upToDate' }) - case 'sidecar': +type Calls = Record) => Promise> + +/** The sidecar as the window sees it: one `sidecar` command, by method. */ +function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Record = {}) { + invokeMock.mockImplementation((command: string, args: { method: string; params: Record }) => { + if (command === 'update_check') return Promise.resolve({ status: 'upToDate' }) + if (command === 'studio_files') return Promise.resolve([]) + if (command !== 'sidecar') return Promise.resolve(undefined) + const override = overrides[args.method] + if (override) return override(args.params) + switch (args.method) { + case 'app.ping': return Promise.resolve({ version: '1.2.3' }) + case 'state.get': + return Promise.resolve({ setup: { step: 'ready', done: true }, settings, projects, drafts }) + case 'chat.open': + return Promise.resolve(view(String(args.params.project))) default: - return Promise.resolve(undefined) + return Promise.resolve(null) } }) } +const sidecarCalls = (method: string) => + invokeMock.mock.calls.filter(([command, args]) => command === 'sidecar' && args.method === method).map(([, args]) => args.params) + describe('StudioApp', () => { it('shows the engine version the sidecar answers with', async () => { - mockStudio({}) + mockSidecar() render() expect((await screen.findByText('Engine 1.2.3')).dataset.testid).toBe('engine') - expect(invokeMock).toHaveBeenCalledWith('sidecar', { method: 'app.ping', params: {} }) - }) - - it('keeps an engine stop that arrives while its version is still on the way', async () => { - let answerPing: (value: unknown) => void = () => {} - mockStudio({ sidecar: () => new Promise((resolve) => (answerPing = resolve)) }) - render() - await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('sidecar', { method: 'app.ping', params: {} })) - await act(async () => listeners.forEach((listener) => listener({ payload: { type: 'engine', state: 'stopped', detail: 'The engine stopped.' } }))) - await act(async () => answerPing({ version: '1.2.3' })) - expect(screen.getByTestId('engine').textContent).toBe('The engine stopped.') }) it('leaves the project the person moved to alone when a slow send from the start screen finishes', async () => { let finishCreate: (project: unknown) => void = () => {} - invokeMock.mockImplementation((command: string) => { - switch (command) { - case 'studio_projects': - return Promise.resolve([projectB]) - case 'studio_create_project': - return new Promise((resolve) => (finishCreate = resolve)) - case 'studio_transcript': - case 'studio_files': - return Promise.resolve([]) - case 'update_check': - return Promise.resolve({ status: 'upToDate' }) - default: - return Promise.resolve(undefined) - } - }) + mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) render() await screen.findByText('Project B') @@ -91,33 +71,56 @@ describe('StudioApp', () => { await screen.findByRole('banner') fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'draft for B' } }) - await act(async () => finishCreate({ id: '2026-11-06-a-clip', title: 'A clip for my desk', createdMs: 2, path: '/p/a' })) - await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('studio_send', expect.objectContaining({ project: '2026-11-06-a-clip' }))) + await act(async () => finishCreate({ id: '2026-11-06-a-clip', conversation: 4, title: 'A clip for my desk', createdMs: 2 })) + await waitFor(() => expect(sidecarCalls('chat.send')).toEqual([expect.objectContaining({ project: '2026-11-06-a-clip', text: 'A clip for my desk' })])) + expect(sidecarCalls('chat.send')[0]?.requestId).toEqual(expect.any(String)) expect(screen.getByRole('banner').textContent).toBe('Project B') expect((screen.getByTestId('prompt') as HTMLTextAreaElement).value).toBe('draft for B') }) - it("keeps a slow send's failure with the project it was sent to", async () => { + it("keeps a slow send's failure with the project it was sent to, and sending again reuses its request id", async () => { let failSend: (reason: string) => void = () => {} - const projectA = { id: '2026-11-06-a', title: 'Project A', createdMs: 2, path: '/p/a' } - mockStudio({ - studio_projects: () => Promise.resolve([projectA, projectB]), - studio_send: () => new Promise((_, reject) => (failSend = reject)), - }) + mockSidecar({ 'chat.send': () => new Promise((_, reject) => (failSend = reject)) }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project A')) await screen.findByRole('banner') fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'A clip for my desk' } }) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('studio_send', expect.objectContaining({ project: projectA.id }))) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) - await act(async () => failSend('The agent could not start.')) + await act(async () => failSend('The engine restarted before it answered. Try again.')) expect(screen.queryByRole('alert')).toBeNull() fireEvent.click(screen.getByText('Project A')) - expect((await screen.findByRole('alert')).textContent).toContain('The agent could not start.') + expect((await screen.findByRole('alert')).textContent).toContain('The engine restarted before it answered.') + fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'A clip for my desk' } }) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + const [first, second] = sidecarCalls('chat.send') + expect(second?.requestId).toBe(first?.requestId) + }) + + it('shows what a waiting turn needs, and a proxy sign-in goes to the sidecar', async () => { + const waiting = { kind: 'login', provider: 'anthropic', name: 'Anthropic', fix: 'Sign in to Anthropic. The message picks up where it stopped.' } + mockSidecar({ 'chat.open': (params) => Promise.resolve(view(String(params.project), { waiting })) }) + render() + fireEvent.click(await screen.findByText('Project B')) + expect((await screen.findByTestId('waiting')).textContent).toContain('Sign in to Anthropic. The message picks up where it stopped.') + fireEvent.change(screen.getByLabelText('API key or token'), { target: { value: 'proxy-token' } }) + fireEvent.change(screen.getByLabelText('Proxy URL'), { target: { value: 'http://127.0.0.1:8317' } }) + fireEvent.click(screen.getByText('Save')) + await waitFor(() => expect(sidecarCalls('auth.setKey')).toEqual([{ provider: 'anthropic', key: 'proxy-token', baseUrl: 'http://127.0.0.1:8317' }])) + }) + + it('saves the draft once typing pauses, and fills the composer with it when the project opens', async () => { + mockSidecar({}, [projectB], { [projectB.id]: { text: 'make it red' } }) + render() + fireEvent.click(await screen.findByText('Project B')) + await waitFor(() => expect((screen.getByTestId('prompt') as HTMLTextAreaElement).value).toBe('make it red')) + fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'make it blue' } }) + await waitFor(() => expect(sidecarCalls('drafts.set')).toEqual([{ key: projectB.id, text: 'make it blue', attachments: [] }])) }) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 4961fc8c..978ca448 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -1,9 +1,12 @@ import { getCurrentWebview } from '@tauri-apps/api/webview' +import { openUrl } from '@tauri-apps/plugin-opener' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { autoOpens, onSidecarEvent, sidecar, studio, type Project, type ProjectFile, type StudioEvent } from './api' +import type { ChatView as View, Project } from '../../sidecar/src/protocol' +import { autoOpens, onSidecarEvent, sidecar, studio, type ProjectFile } from './api' import { ChatView, Composer, releaseAttachment, type Attachment } from './Chat' import RightPanel, { type Tab } from './RightPanel' import { buildChat } from './transcript' +import { WaitingBanner } from './Waiting' import { UpdateBanner } from '../components/UpdateBanner' import { checkForUpdatesAtLaunch, useUpdateEvents } from '../stores/updates' import './studio.css' @@ -16,10 +19,11 @@ const SUGGESTIONS = [ const PICTURE = /\.(png|jpe?g|heic|heif|webp|gif)$/i -/** Adds a live event at its transcript line. One the loaded history already holds is a repeat. */ -function appendEvent(events: StudioEvent[], seq: number, event: StudioEvent): StudioEvent[] { - return seq < events.length ? events : [...events, event] -} +/** The unsent message of the start screen, before a project exists. */ +const START = 'new' + +/** How long typing pauses before the draft is saved. */ +const DRAFT_PAUSE_MS = 400 function newestAutoOpen(files: ProjectFile[]): ProjectFile | undefined { return files.filter((f) => autoOpens(f.rel)).sort((a, b) => b.modifiedMs - a.modifiedMs)[0] @@ -28,7 +32,7 @@ function newestAutoOpen(files: ProjectFile[]): ProjectFile | undefined { export default function StudioApp() { const [projects, setProjects] = useState([]) const [current, setCurrent] = useState(null) - const [events, setEvents] = useState([]) + const [view, setView] = useState(null) const [files, setFiles] = useState([]) const [selected, setSelected] = useState(null) const [tab, setTab] = useState('preview') @@ -36,20 +40,23 @@ export default function StudioApp() { const [error, setError] = useState(null) /** A failed send's error, kept with the project it was sent to and shown only there. */ const [sendErrors, setSendErrors] = useState>({}) - /** The unsent message. It belongs to the open project and stays until a send succeeds. */ + /** The unsent message. The sidecar keeps it as the open project's draft. */ const [text, setText] = useState('') const [sending, setSending] = useState(false) const [engine, setEngine] = useState('Engine starting') const sendingNow = useRef(false) const currentId = useRef(null) - /** Bumped on every switch, so a slow history load for a project already left is dropped. */ + /** Bumped on every switch, so a slow load for a project already left is dropped. */ const loadRequest = useRef(0) - /** Live events that arrive while the open project's history is loading. */ - const pending = useRef<{ seq: number; event: StudioEvent }[] | null>(null) /** The newest file time already shown, so only newer files take over the preview. */ const lastSeen = useRef(0) + /** Saved drafts as the sidecar last gave them, to fill the composer when a project opens. */ + const drafts = useRef>({}) + /** A send that has not gone through yet keeps its request id, so sending it again never sends it twice. */ + const pending = useRef<{ key: string; id: string } | null>(null) - const chat = useMemo(() => buildChat(events, current?.path ?? ''), [events, current?.path]) + const items = useMemo(() => buildChat(view?.entries ?? []), [view?.entries]) + const running = view?.running ?? false useUpdateEvents() useEffect(() => { @@ -57,33 +64,60 @@ export default function StudioApp() { }, []) useEffect(() => { - // The listener is in place before the ping, so no change is missed, and a ping that answers late never hides one. - let changed = false + sidecar('app.ping', {}) + .then(({ version }) => setEngine(`Engine ${version}`)) + .catch((err: unknown) => setEngine(String(err))) + sidecar('state.get', {}) + .then((state) => { + drafts.current = state.drafts + setProjects(state.projects) + if (currentId.current === null) setText(state.drafts[START]?.text ?? '') + }) + .catch((err: unknown) => setError(String(err))) const unlisten = onSidecarEvent((event) => { - if (event.type !== 'ready' && event.type !== 'engine') return - changed = true - setEngine(event.type === 'ready' ? `Engine ${event.version}` : event.state === 'stopped' ? event.detail : 'Engine restarting') + switch (event.type) { + case 'ready': + setEngine(`Engine ${event.version}`) + break + case 'engine': + setEngine(event.state === 'stopped' ? event.detail : 'Engine restarting') + break + case 'chat': + if (event.project !== currentId.current) break + setView((now) => { + if (now === null || now.project !== event.project) return now + const last = now.entries[now.entries.length - 1]?.id ?? 0 + const added = event.entries.filter((entry) => entry.id > last) + return { ...now, entries: added.length > 0 ? [...now.entries, ...added] : now.entries, running: event.running, waiting: event.waiting, turn: event.turn } + }) + break + case 'login': + if (event.step === 'open') void openUrl(event.url).catch((err: unknown) => setError(String(err))) + if (event.step === 'ask') { + // A choice is answered with its option's id, so the question lists them. + const choices = event.options?.map((option) => `${option.id}: ${option.label}`).join('\n') + const answer = window.prompt(choices === undefined ? event.message : `${event.message}\n${choices}`) + if (answer !== null) void sidecar('auth.answer', { provider: event.provider, text: answer }).catch((err: unknown) => setError(String(err))) + } + if (event.step === 'failed') setError(event.detail) + break + } }) - unlisten - .then(() => sidecar('app.ping', {})) - .then(({ version }) => changed || setEngine(`Engine ${version}`)) - .catch((err: unknown) => changed || setEngine(String(err))) return () => { void unlisten.then((stop) => stop()) } }, []) + // The draft is saved a moment after typing stops, for the open project or the start screen. useEffect(() => { - studio.projects().then(setProjects).catch((err: unknown) => setError(String(err))) - const unlisten = studio.onEvent((project, seq, event) => { - if (project !== currentId.current) return - if (pending.current) pending.current.push({ seq, event }) - else setEvents((prev) => appendEvent(prev, seq, event)) - }) - return () => { - void unlisten.then((stop) => stop()) - } - }, []) + const key = current?.id ?? START + if ((drafts.current[key]?.text ?? '') === text) return + const timer = window.setTimeout(() => { + drafts.current = { ...drafts.current, [key]: { text } } + void sidecar('drafts.set', { key, text, attachments: [] }).catch((err: unknown) => setError(String(err))) + }, DRAFT_PAUSE_MS) + return () => window.clearTimeout(timer) + }, [text, current?.id]) // Pasted pictures still waiting when the studio closes give their previews back. const unsent = useRef(attachments) @@ -122,26 +156,25 @@ export default function StudioApp() { useEffect(() => { if (!current) return void refreshFiles(current.id).catch((err: unknown) => setError(String(err))) - if (!chat.running) return + if (!running) return const timer = window.setInterval(() => { void refreshFiles(current.id).catch((err: unknown) => setError(String(err))) }, 1500) return () => window.clearInterval(timer) - }, [current, chat.running, refreshFiles]) + }, [current, running, refreshFiles]) - /** Opens a project, or the start screen. A move the person makes drops the unsent message, so it never lands in another project. */ - const show = (project: Project | null, history: StudioEvent[], keepDraft = false) => { - if (!keepDraft) { - setText('') + /** Shows a project, or the start screen, with its saved draft. */ + const show = (project: Project | null, keepText = false) => { + if (!keepText) { + setText(drafts.current[project?.id ?? START]?.text ?? '') attachments.forEach(releaseAttachment) setAttachments([]) } loadRequest.current += 1 - pending.current = null currentId.current = project?.id ?? null lastSeen.current = 0 setCurrent(project) - setEvents(history) + setView(null) setFiles([]) setSelected(null) setTab('preview') @@ -149,39 +182,36 @@ export default function StudioApp() { } const openProject = async (project: Project) => { - show(project, []) + show(project) const request = loadRequest.current - pending.current = [] try { - const history = await studio.transcript(project.id) - if (request !== loadRequest.current) return - const live = pending.current - pending.current = null - setEvents(live.reduce((events, { seq, event }) => appendEvent(events, seq, event), history)) + const opened = await sidecar('chat.open', { project: project.id }) + if (request === loadRequest.current) setView(opened) } catch (err) { - if (request !== loadRequest.current) return - pending.current = null - setError(String(err)) + if (request === loadRequest.current) setError(String(err)) } } const send = async () => { const message = text.trim() - if ((!message && attachments.length === 0) || sendingNow.current || chat.running) return + if ((!message && attachments.length === 0) || sendingNow.current || running) return sendingNow.current = true setSending(true) // The view this send belongs to. A move to another project or the start screen // while it runs changes `loadRequest`, and then the send leaves that view alone. - let view = loadRequest.current + let request = loadRequest.current let sentTo: string | null = null try { let project = current if (!project) { - project = await studio.createProject(message || 'New part') - setProjects((prev) => [project!, ...prev]) - if (loadRequest.current === view) { - show(project, [], true) - view = loadRequest.current + const created = await sidecar('projects.create', { title: message || 'New part' }) + project = created + setProjects((prev) => [created, ...prev]) + if (loadRequest.current === request) { + show(created, true) + request = loadRequest.current + const opened = await sidecar('chat.open', { project: created.id }) + if (loadRequest.current === request) setView(opened) } } const id = project.id @@ -190,10 +220,14 @@ export default function StudioApp() { const images = await Promise.all( outgoing.map((a) => (a.kind === 'path' ? studio.importFile(id, a.path) : studio.saveBytes(id, a.name, a.bytes))), ) - await studio.send(id, message, images) + const key = [id, message, ...images].join('\n') + if (pending.current?.key !== key) pending.current = { key, id: crypto.randomUUID() } + await sidecar('chat.send', { project: id, requestId: pending.current.id, text: message, attachments: images }) + pending.current = null + drafts.current = { ...drafts.current, [id]: { text: '' } } // Text typed while this was sending is a new message, so only the sent text is cleared, // and only in the view that sent it. - if (loadRequest.current === view) setText((now) => (now.trim() === message ? '' : now)) + if (loadRequest.current === request) setText((now) => (now.trim() === message ? '' : now)) // A picture added while these imported waits for the next message. setAttachments((prev) => prev.filter((a) => !outgoing.includes(a))) outgoing.forEach(releaseAttachment) @@ -202,7 +236,7 @@ export default function StudioApp() { if (sentTo !== null) { const project = sentTo setSendErrors((prev) => ({ ...prev, [project]: message })) - } else if (loadRequest.current === view) { + } else if (loadRequest.current === request) { // No project was made, so the error belongs to the start screen, if it is still open. setError(message) } @@ -229,9 +263,9 @@ export default function StudioApp() { setAttachments((prev) => prev.filter((_, i) => i !== index)) }} onSend={() => void send()} - onStop={current ? () => void studio.cancel(current.id) : undefined} - busy={sending || chat.running} - running={chat.running} + onStop={current ? () => void sidecar('chat.stop', { project: current.id }).catch((err: unknown) => setError(String(err))) : undefined} + busy={sending || running} + running={running} placeholder={placeholder} autoFocus={autoFocus} /> @@ -240,7 +274,7 @@ export default function StudioApp() { return (
)} + {signIn && ( + { + setSignIn(null) + void sidecar('auth.answer', { provider: signIn.provider, text: answer }).catch((err: unknown) => setError(String(err))) + }} + /> + )} {current ? ( <>
{current.title}
@@ -326,7 +395,7 @@ export default function StudioApp() { {composer('Describe the part. Paste a link or drop a photo if you have one.', true)}
{SUGGESTIONS.map((s) => ( - ))} diff --git a/src/studio/Waiting.tsx b/src/studio/Waiting.tsx index 822be6e1..a1c1fa3b 100644 --- a/src/studio/Waiting.tsx +++ b/src/studio/Waiting.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { ProviderId, Waiting } from '../../sidecar/src/protocol' +import type { LoginEvent, ProviderId, Waiting } from '../../sidecar/src/protocol' import { sidecar } from './api' /** Providers a key or a proxy can sign in to, and those with a subscription sign-in. */ @@ -53,3 +53,49 @@ export function WaitingBanner({ project, waiting, onError }: { project: string;
) } + +/** What a sign-in needs from the person: the code to enter on the page it opened, or an answer to its question. */ +export function SignInAsk({ ask, onAnswer }: { ask: Extract; onAnswer: (text: string) => void }) { + const [text, setText] = useState('') + if (ask.step === 'open') { + return ( +
+ + Enter the code {ask.code} at {ask.url} + +
+ ) + } + return ( +
+ {ask.kind === 'select' ? ( + <> + {ask.message} + {ask.options?.map((option) => ( + + ))} + + ) : ( +
{ + event.preventDefault() + onAnswer(text) + }} + > + setText(e.target.value)} + /> + +
+ )} +
+ ) +} From c635ad9e9efe9dec93f5880cd44143af3c02b14a Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 00:09:04 -0600 Subject: [PATCH 03/59] chore(formal): the update models still describe the sidecar after the round-3 fixes The refused-sign-in wait and its wiring changed two hashed files, chat.ts and app.ts. Neither changes when the pass is taken, given back, or refused. TLC: all nine verdicts match, and all ten mutants fail as they must. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 277f9e32..c5aa2a0f 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs cf930114c31d3a191eb461ad23a7afb95162277f710a417ea73cc88d501d8e4e src-tauri/src/sidecar.rs 25b4a8bd80195388915f90129c7573471497a0fa601611ad937cf0304414929d sidecar/src/busy.ts -ea82bd551884c9d3595e7275d7bb9741196e0f45d3e46fc0a1830a63b75d9ab9 sidecar/src/chat.ts -646b6b74bc5e5464163dea292c4adba959e638d8c6f0a18a42e32e27f395ace4 sidecar/src/app.ts +9a9aef6b674d0a5a206725fd729c4073364a590cfd185a467d637eb634c03bc5 sidecar/src/chat.ts +0fa6a00eebb66f303726b0f1f2863da7e2e8b9c3c3ef6c2fe15dcb5d4876734c sidecar/src/app.ts From aed4f0ce5bd20731f0879ba817ba62d279d7a68b Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 00:25:25 -0600 Subject: [PATCH 04/59] fix(sidecar): the update pass stays held between a tool's result and the turn's next step A turn whose tool finished waits on it for a moment before it goes on. The pass counted only pending and running tasks, so it was given back and taken again in that moment, and an update could restart the app mid-turn. The update gate test caught it two runs in three. Every live task now holds the pass, but a tool parked on a sign-in, known by its task id, and a task that waits only on such tools. TLC: all nine verdicts match, and all ten mutants fail as they must. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 6 +-- sidecar/src/agent.ts | 8 ++-- sidecar/src/app.ts | 2 +- sidecar/src/busy.ts | 41 +++++++++++-------- sidecar/src/chat.ts | 4 +- 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index c5aa2a0f..b99477eb 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -5,6 +5,6 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs cf930114c31d3a191eb461ad23a7afb95162277f710a417ea73cc88d501d8e4e src-tauri/src/sidecar.rs -25b4a8bd80195388915f90129c7573471497a0fa601611ad937cf0304414929d sidecar/src/busy.ts -9a9aef6b674d0a5a206725fd729c4073364a590cfd185a467d637eb634c03bc5 sidecar/src/chat.ts -0fa6a00eebb66f303726b0f1f2863da7e2e8b9c3c3ef6c2fe15dcb5d4876734c sidecar/src/app.ts +5f14a83058126192cf880eca86f5b1cb41ff168a60c8e2930bff580b6f121930 sidecar/src/busy.ts +267405a704033081d29f068482d525d939c73f7ad710836121f930fbf92672b9 sidecar/src/chat.ts +1c744f8cdf74d4ce76a1000f7e77a6fc6da8b7c0acf210b97b38e4c1d63bc89a sidecar/src/app.ts diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index a5848355..e60f6492 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -188,9 +188,9 @@ export interface AgentHost { /** * Resolves once the person is signed in to `provider`: at once when they are, after holding the turn with the fix * named on its chat when they are not. With `after`, only a login saved after that many saves will do, as the one - * then saved was refused. A stop ends the wait. + * then saved was refused. The tool's `task` gives the update pass back while it waits. A stop ends the wait. */ - signedIn(conversation: number, provider: string, context: Context, after: number | undefined): Promise + signedIn(conversation: number, task: string, provider: string, context: Context, after: number | undefined): Promise /** How many times the provider's login was saved. */ loginSaves(provider: string): Promise } @@ -307,7 +307,7 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) // The login save each request goes out with, kept with the request, so a rerun after a crash finds the same one. const savedWith = async (requestId: string) => api.memo(`login:${requestId}`, await loginSaves(provider), context) // Astra runs on the person's ChatGPT sign-in. Without it the turn waits here, with the fix named, and goes on. - await signedIn(api.conversationId, provider, context, undefined) + await signedIn(api.conversationId, String(api.taskId), provider, context, undefined) const first = `astra:${api.taskId}` let saves = await savedWith(first) let submission = await conversation.submit({ type: 'input', content: task, requestId: first }, context) @@ -328,7 +328,7 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) // a continue it already sent as it was, without waiting again. const requestId = `astra:${api.taskId}:continue:${resumed}` const sent = await api.commit((tx) => tx.submissionByRequest(child, requestId), context) - if (sent === undefined) await signedIn(api.conversationId, provider, context, saves) + if (sent === undefined) await signedIn(api.conversationId, String(api.taskId), provider, context, saves) saves = await savedWith(requestId) submission = await conversation.submit({ type: 'continue', requestId }, context) } diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index 6dab11c5..2423ac87 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -46,7 +46,7 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis const tools = agentTools({ host, projectOf: (conversation) => store.projectOf(conversation), - signedIn: (conversation, provider, context, after) => chats.loginFor(conversation, provider, context, after), + signedIn: (conversation, task, provider, context, after) => chats.loginFor(conversation, task, provider, context, after), loginSaves: (provider) => store.loginSaves(provider), }) const extension = agentExtension(tools) diff --git a/sidecar/src/busy.ts b/sidecar/src/busy.ts index 9dc258ae..ce72995d 100644 --- a/sidecar/src/busy.ts +++ b/sidecar/src/busy.ts @@ -1,25 +1,34 @@ /** * Tells Rust while the store has work in flight, so an update waits to restart the app until the work is done. The - * pass is asked for before a turn's first commit and before work a crash cut off resumes, and given back once no task - * is pending or running and no send is committing. A task that waits, sleeps, or holds a sign-in wait does no work - * until something wakes it, and it resumes after a restart, so an update may restart through it. + * pass is asked for before a turn's first commit and before work a crash cut off resumes, and given back once no send + * is committing and every live task is idle. A tool parked on a sign-in is idle, and so is a task that waits only on + * idle tasks: they do no work until the person signs in, and they resume after a restart, so an update may restart + * through them. A task that waits on a tool that finished is about to go on, so it is not idle. * While an update installs, Rust refuses the pass, and the send with it. */ import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' import type { Harness, TaskGraph } from '@earendil-works/pi-durable' import type { Host } from './host' -/** Tasks doing work now. A blocked task never runs here. */ -const working = (graph: TaskGraph) => - Object.values(graph.tasks).filter((task) => (task.state.status === 'pending' || task.state.status === 'running') && task.blocked === undefined).length +/** Whether any live task does work now or is about to. A blocked task never runs here. */ +function working(graph: TaskGraph, parked: ReadonlySet): boolean { + const idle = (id: string): boolean => { + if (parked.has(id)) return true + const task = graph.tasks[id] + if (task === undefined) return false + if (task.blocked !== undefined) return true + return task.state.status === 'waiting' && task.state.on.every((child) => idle(String(child))) + } + return Object.keys(graph.tasks).some((id) => !idle(id)) +} export class Busy { readonly #host: Host /** Whether Rust said yes to the pass and it was not given back since. */ #held = false - #tasks = 0 - /** Running tool tasks parked on a sign-in, which do no work until the person signs in. */ - #parked = 0 + #graph: TaskGraph = { tasks: {} } + /** The ids of running tool tasks parked on a sign-in, which do no work until the person signs in. */ + readonly #parked = new Set() #sends = 0 /** Changes reach Rust one at a time, in order. */ #told: Promise = Promise.resolve() @@ -34,14 +43,14 @@ export class Busy { */ async watch(harness: Harness): Promise { const graph = await harness.watchTaskGraph(BACKGROUND_CONTEXT) - this.#tasks = working(graph.value) + this.#graph = graph.value const ok = await this.#settle().then( () => true, () => false, ) graph.start(async (call) => { if (call.kind !== 'value') return - this.#tasks = working(call.value) + this.#graph = call.value await this.#follow() }) return ok @@ -60,7 +69,7 @@ export class Busy { /** Brings Rust's pass in line with the work now. Rejects with Rust's refusal when this call asked and was refused. */ #settle(): Promise { const told = this.#told.then(async () => { - const busy = this.#sends > 0 || this.#tasks - this.#parked > 0 + const busy = this.#sends > 0 || working(this.#graph, this.#parked) if (busy === this.#held) return await this.#host.request('busy', { busy }) this.#held = busy @@ -84,14 +93,14 @@ export class Busy { } } - /** Gives the pass back while a running tool waits on `wait` for a sign-in, and asks for it again when it goes on. */ - async parked(wait: () => Promise): Promise { - this.#parked += 1 + /** Gives the pass back while the tool `task` waits on `wait` for a sign-in, and asks for it again when it goes on. */ + async parked(task: string, wait: () => Promise): Promise { + this.#parked.add(task) void this.#follow() try { return await wait() } finally { - this.#parked -= 1 + this.#parked.delete(task) await this.#follow() } } diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 516ab0e0..9edde671 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -263,7 +263,7 @@ export class Chats { * named on its chat, and goes on once they sign in. A stop ends the wait. A wait the tool held before a restart is * let go once the sign-in is there. */ - async loginFor(conversation: number, provider: string, waiting: Context, after: number | undefined): Promise { + async loginFor(conversation: number, task: string, provider: string, waiting: Context, after: number | undefined): Promise { const { models, store } = this.#o const id = conversation as ConversationId const name = models.getProvider(provider)?.name ?? provider @@ -292,7 +292,7 @@ export class Chats { await hold({ kind: 'login', provider, name, fix }) await this.#show(project) } - await this.#o.busy.parked(() => awaitWithContext(promise, waiting)) + await this.#o.busy.parked(task, () => awaitWithContext(promise, waiting)) } finally { waiters.delete(resolve) } From a397de69f185514c42c66907393104f058e4adb8 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 00:46:13 -0600 Subject: [PATCH 05/59] fix(studio): work goes on only with the update pass, and drafts and retries stay with their project - A tool that waited on a sign-in went on after it, even while an update installed and Rust refused the pass. It now asks again each second until it holds the pass: the update restarts the app, or it fails and the gate opens. - Work a crash cut off, found while an update installed, waited for the restart only. If the install failed, it never resumed. The sidecar now keeps asking for the pass and resumes the work once it holds it. - Text typed while a new project was made went to the start screen's draft. It is now the new project's draft. - A slow saved state filled a start screen the person had typed in and then cleared. Once the person types there, the saved draft no longer fills it. - A send in another project dropped this project's unsent message, so sending it again took a new request id and could send it twice. Unsent messages are now kept per project. TLC: all nine verdicts match, and all ten mutants fail as they must. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 4 +- sidecar/src/app.ts | 12 ++++- sidecar/src/busy.ts | 33 ++++++++++-- sidecar/test/switch.test.ts | 40 ++++++++++++++ src/studio/StudioApp.test.tsx | 53 +++++++++++++++++++ src/studio/StudioApp.tsx | 23 ++++---- 6 files changed, 149 insertions(+), 16 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index b99477eb..5435d05d 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -5,6 +5,6 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs cf930114c31d3a191eb461ad23a7afb95162277f710a417ea73cc88d501d8e4e src-tauri/src/sidecar.rs -5f14a83058126192cf880eca86f5b1cb41ff168a60c8e2930bff580b6f121930 sidecar/src/busy.ts +9d7d3dca18efa0a50d34d6723069d354f2686f0e53de53d7e50f522b76b5a38b sidecar/src/busy.ts 267405a704033081d29f068482d525d939c73f7ad710836121f930fbf92672b9 sidecar/src/chat.ts -1c744f8cdf74d4ce76a1000f7e77a6fc6da8b7c0acf210b97b38e4c1d63bc89a sidecar/src/app.ts +aeb72aace3e667c2b136481d090629082f6ab584178bf0516bf3a4b1d92e6376 sidecar/src/app.ts diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index 2423ac87..eaecb8df 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -100,7 +100,17 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis const resumable = await busy.watch(store.harness) host.emit({ type: 'ready', version }) if (resumable) await chats.reconcile() - else console.error('an update is installing, so the work a crash cut off resumes after the restart') + else { + // The update restarts the app, and the work resumes then. If the install fails instead, the gate opens again. + console.error('an update is installing, so the work a crash cut off waits for it') + const quit = new AbortController() + const stop = () => quit.abort() + serving.then(stop, stop) + void busy + .pass(quit.signal) + .then((held) => (held ? chats.reconcile() : undefined)) + .catch((error: unknown) => console.error(`resuming the work a crash cut off failed: ${String(error)}`)) + } await serving await chats.close() await store.close() diff --git a/sidecar/src/busy.ts b/sidecar/src/busy.ts index ce72995d..d0fa484b 100644 --- a/sidecar/src/busy.ts +++ b/sidecar/src/busy.ts @@ -10,6 +10,9 @@ import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' import type { Harness, TaskGraph } from '@earendil-works/pi-durable' import type { Host } from './host' +/** How long a refused pass waits before it asks again. An installing update restarts the app, or it fails and the gate opens. */ +const PASS_RETRY_MS = 1000 + /** Whether any live task does work now or is about to. A blocked task never runs here. */ function working(graph: TaskGraph, parked: ReadonlySet): boolean { const idle = (id: string): boolean => { @@ -66,6 +69,22 @@ export class Busy { }) } + /** + * Asks for the pass until Rust gives it, for work that must not go on without it, and resolves true once it is held + * or no work needs it. While an update installs, it asks again each second. Resolves false once `stop` aborts. + */ + async pass(stop?: AbortSignal): Promise { + for (;;) { + if (stop?.aborted) return false + try { + await this.#settle() + return true + } catch { + await Bun.sleep(PASS_RETRY_MS) + } + } + } + /** Brings Rust's pass in line with the work now. Rejects with Rust's refusal when this call asked and was refused. */ #settle(): Promise { const told = this.#told.then(async () => { @@ -93,15 +112,23 @@ export class Busy { } } - /** Gives the pass back while the tool `task` waits on `wait` for a sign-in, and asks for it again when it goes on. */ + /** + * Gives the pass back while the tool `task` waits on `wait` for a sign-in. Once the wait ends, the tool goes on only + * with the pass, so an update that started installing meanwhile holds it until the app restarts or the install fails. + */ async parked(task: string, wait: () => Promise): Promise { this.#parked.add(task) void this.#follow() + let result: T try { - return await wait() - } finally { + result = await wait() + } catch (error) { this.#parked.delete(task) await this.#follow() + throw error } + this.#parked.delete(task) + await this.pass() + return result } } diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index c3a5609f..44ed21b5 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -142,6 +142,46 @@ describe('the update gate', () => { await after.quit() }, 30_000) + test('after an install that failed, the work a crash cut off goes on once the gate opens again', async () => { + const where = scratch() + const first = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + await first.next() + await first.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } + await first.call('chat.send', { project, requestId: 'r1', text: 'please wait', attachments: [] }) + const started = join(where.projects, project, 'started') + while (!existsSync(started)) await Bun.sleep(25) + first.child.kill('SIGKILL') + await first.child.exited + rmSync(started) + + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + app.gate.installing = true + await app.until((event) => event.type === 'ready') + await Bun.sleep(1_500) + expect(existsSync(started)).toBe(false) + app.gate.installing = false + while (!existsSync(started)) await Bun.sleep(25) + await app.quit() + }, 30_000) + + test('a tool that waited on a sign-in goes on only with the pass, so an install that started meanwhile holds it', async () => { + const where = scratch() + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.send', { project, requestId: 'r1', text: 'go', attachments: [] }) + await app.until((event) => event.type === 'chat' && event.waiting !== null) + app.gate.installing = true + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + await Bun.sleep(2_000) + expect(existsSync(join(where.projects, project, 'astra.txt'))).toBe(false) + app.gate.installing = false + await app.until((event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant' && entry.text === 'parent saw: astra drew it'), 30_000) + await app.quit() + }, 40_000) + test('gives the pass back while a turn waits on a sign-in, and takes it again when the turn goes on', async () => { const where = scratch() const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra', env: where.env }) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 0f0aa1a2..99060328 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -227,6 +227,59 @@ describe('StudioApp', () => { expect(prompt().value).toBe('') }) + it("keeps text typed while a new project is made as that project's draft", async () => { + let finishCreate: (project: unknown) => void = () => {} + mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) + render() + await screen.findByText('Project B') + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) + type('second message') + await act(async () => finishCreate(projectA)) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('second message')) + }) + + it('leaves the start screen empty when the person cleared it before the saved state arrived', async () => { + let finishState: (state: unknown) => void = () => {} + mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) + render() + type('A clip for my desk') + type('') + await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'an older draft' } } })) + expect(prompt().value).toBe('') + }) + + it("keeps each project's unsent message, so sending it again after another project's send reuses its request id", async () => { + let sends = 0 + mockSidecar( + { 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)) }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + type('make it blue') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(3)) + const [first, , third] = sidecarCalls('chat.send') + expect(third?.requestId).toBe(first?.requestId) + }) + it('shows the code a device sign-in asks for, with its page', async () => { mockSidecar() render() diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 034d05df..30bab958 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -69,10 +69,12 @@ export default function StudioApp() { /** Typed text not saved yet, and the timer that saves it. */ const unsaved = useRef<{ key: string; text: string; timer: number } | null>(null) /** - * A message whose send has not gone through. Sending it again sends the same pictures under the same request id, so - * a send whose reply was lost never sends it twice. + * Messages whose send has not gone through, by project. Sending one again sends the same pictures under the same + * request id, so a send whose reply was lost never sends it twice. */ - const pending = useRef<{ project: string; text: string; outgoing: Attachment[]; images: string[]; id: string } | null>(null) + const pending = useRef(new Map()) + /** Whether the person typed on the start screen, so the saved draft no longer fills it. */ + const typedOnStart = useRef(false) /** The project whose chat view is loaded. Chat changes for the open project wait in `early` until it is. */ const loaded = useRef(null) const early = useRef([]) @@ -94,7 +96,7 @@ export default function StudioApp() { // Drafts saved in this window since are newer, and text already typed stays. drafts.current = { ...state.drafts, ...drafts.current } setProjects(state.projects) - if (currentId.current === null) setText((now) => (now === '' ? (drafts.current[START]?.text ?? '') : now)) + if (currentId.current === null && !typedOnStart.current) setText(drafts.current[START]?.text ?? '') }) .catch((err: unknown) => setError(String(err))) const unlisten = onSidecarEvent((event) => { @@ -147,6 +149,7 @@ export default function StudioApp() { if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) const timer = window.setTimeout(() => void saveDraft().catch((err: unknown) => setError(String(err))), DRAFT_PAUSE_MS) unsaved.current = { key: currentId.current ?? START, text: value, timer } + if (currentId.current === null) typedOnStart.current = true } useEffect(() => () => window.clearTimeout(unsaved.current?.timer), []) @@ -252,6 +255,8 @@ export default function StudioApp() { // The sidecar moved the start screen's draft to the new project in the same commit. const { [START]: started, ...rest } = drafts.current drafts.current = started === undefined ? rest : { ...rest, [created.id]: started } + // Text typed while the project was made is its draft too. + if (unsaved.current?.key === START) unsaved.current.key = created.id setProjects((prev) => [created, ...prev]) if (loadRequest.current === request) { show(created, true) @@ -262,17 +267,15 @@ export default function StudioApp() { const id = project.id sentTo = id const outgoing = attachments - const last = pending.current + const last = pending.current.get(id) const again = - last !== null && - last.project === id && + last !== undefined && last.text === message && last.outgoing.length === outgoing.length && last.outgoing.every((a, i) => a === outgoing[i]) const sendable = again ? last : { - project: id, text: message, outgoing, images: await Promise.all( @@ -280,9 +283,9 @@ export default function StudioApp() { ), id: crypto.randomUUID(), } - pending.current = sendable + pending.current.set(id, sendable) await sidecar('chat.send', { project: id, requestId: sendable.id, text: message, attachments: sendable.images }) - pending.current = null + pending.current.delete(id) if (drafts.current[id]?.text.trim() === message) drafts.current = { ...drafts.current, [id]: { text: '' } } // Text typed while this was sending is a new message, so only the sent text is cleared, // and only in the view that sent it. From eab0a5a7affb07da85d2d58a44430845303d0dbf Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 01:14:52 -0600 Subject: [PATCH 06/59] fix(studio): a stop or a quit ends the wait for the pass, and a draft moves only with its own message - A tool that signed in while an update installed waited for the pass with no way out: Stop did not end the turn, and a quit did not end the sidecar. The wait now ends on the tool's own stop, and as the sidecar closes it leaves the tool unfinished for Pi to end, so the tool resumes after the restart. - A project made from the start screen took the start screen's draft with it, whatever it held, both in the store and in the window. A late reply could so take a draft the person typed after they left and came back, and text saved by its pause while the project was made stayed behind. A draft now moves only when it holds the project's first message. Text typed while the project was made follows the person to it when they stayed on that screen. - The update gate test expects a pass asked for again each second. TLC: all nine verdicts match, and all ten mutants fail as they must. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 6 +-- sidecar/src/app.ts | 6 +-- sidecar/src/busy.ts | 38 +++++++++++++--- sidecar/src/chat.ts | 2 +- sidecar/src/store.ts | 9 ++-- sidecar/test/store.test.ts | 10 +++++ sidecar/test/switch.test.ts | 43 ++++++++++++++++++- src/studio/StudioApp.test.tsx | 24 +++++++++++ src/studio/StudioApp.tsx | 20 +++++++-- 9 files changed, 135 insertions(+), 23 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 5435d05d..ec58281e 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -5,6 +5,6 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs cf930114c31d3a191eb461ad23a7afb95162277f710a417ea73cc88d501d8e4e src-tauri/src/sidecar.rs -9d7d3dca18efa0a50d34d6723069d354f2686f0e53de53d7e50f522b76b5a38b sidecar/src/busy.ts -267405a704033081d29f068482d525d939c73f7ad710836121f930fbf92672b9 sidecar/src/chat.ts -aeb72aace3e667c2b136481d090629082f6ab584178bf0516bf3a4b1d92e6376 sidecar/src/app.ts +015742ffd6bd28e2d1af8889c15a01f378289df09efc88726446808b17b6a3a4 sidecar/src/busy.ts +d87ab72442d48f33334e17e6ebdfd1660f7b0753ee51f1703b8ac6f3706149f3 sidecar/src/chat.ts +97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index eaecb8df..b2e2c1ea 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -103,15 +103,13 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis else { // The update restarts the app, and the work resumes then. If the install fails instead, the gate opens again. console.error('an update is installing, so the work a crash cut off waits for it') - const quit = new AbortController() - const stop = () => quit.abort() - serving.then(stop, stop) void busy - .pass(quit.signal) + .pass() .then((held) => (held ? chats.reconcile() : undefined)) .catch((error: unknown) => console.error(`resuming the work a crash cut off failed: ${String(error)}`)) } await serving + busy.close() await chats.close() await store.close() process.exit(EXIT.done) diff --git a/sidecar/src/busy.ts b/sidecar/src/busy.ts index d0fa484b..cab897f2 100644 --- a/sidecar/src/busy.ts +++ b/sidecar/src/busy.ts @@ -13,6 +13,19 @@ import type { Host } from './host' /** How long a refused pass waits before it asks again. An installing update restarts the app, or it fails and the gate opens. */ const PASS_RETRY_MS = 1000 +/** Waits `ms`, or less once `stop` aborts. */ +function pause(ms: number, stop: AbortSignal): Promise { + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer) + stop.removeEventListener('abort', done) + resolve() + } + const timer = setTimeout(done, ms) + stop.addEventListener('abort', done) + }) +} + /** Whether any live task does work now or is about to. A blocked task never runs here. */ function working(graph: TaskGraph, parked: ReadonlySet): boolean { const idle = (id: string): boolean => { @@ -33,6 +46,8 @@ export class Busy { /** The ids of running tool tasks parked on a sign-in, which do no work until the person signs in. */ readonly #parked = new Set() #sends = 0 + /** Ends every wait for the pass once the sidecar closes. */ + readonly #closing = new AbortController() /** Changes reach Rust one at a time, in order. */ #told: Promise = Promise.resolve() @@ -71,20 +86,27 @@ export class Busy { /** * Asks for the pass until Rust gives it, for work that must not go on without it, and resolves true once it is held - * or no work needs it. While an update installs, it asks again each second. Resolves false once `stop` aborts. + * or no work needs it. While an update installs, it asks again each second. Resolves false once `stop` aborts or + * the sidecar closes. */ async pass(stop?: AbortSignal): Promise { + const ended = stop === undefined ? this.#closing.signal : AbortSignal.any([stop, this.#closing.signal]) for (;;) { - if (stop?.aborted) return false + if (ended.aborted) return false try { await this.#settle() return true } catch { - await Bun.sleep(PASS_RETRY_MS) + await pause(PASS_RETRY_MS, ended) } } } + /** Ends every wait for the pass, as the sidecar closes. */ + close(): void { + this.#closing.abort() + } + /** Brings Rust's pass in line with the work now. Rejects with Rust's refusal when this call asked and was refused. */ #settle(): Promise { const told = this.#told.then(async () => { @@ -115,8 +137,9 @@ export class Busy { /** * Gives the pass back while the tool `task` waits on `wait` for a sign-in. Once the wait ends, the tool goes on only * with the pass, so an update that started installing meanwhile holds it until the app restarts or the install fails. + * A stop through `stop`, or the sidecar closing, ends that wait too. */ - async parked(task: string, wait: () => Promise): Promise { + async parked(task: string, wait: () => Promise, stop: AbortSignal | undefined): Promise { this.#parked.add(task) void this.#follow() let result: T @@ -128,7 +151,10 @@ export class Busy { throw error } this.#parked.delete(task) - await this.pass() - return result + if (await this.pass(stop)) return result + // A stop ends the tool. A sidecar that closes leaves it unfinished until Pi ends it as the store closes, so it + // resumes after the restart. + stop?.throwIfAborted() + return new Promise((_, reject) => stop?.addEventListener('abort', () => reject(stop.reason), { once: true })) } } diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 9edde671..e7657601 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -292,7 +292,7 @@ export class Chats { await hold({ kind: 'login', provider, name, fix }) await this.#show(project) } - await this.#o.busy.parked(task, () => awaitWithContext(promise, waiting)) + await this.#o.busy.parked(task, () => awaitWithContext(promise, waiting), waiting.abortSignal) } finally { waiters.delete(resolve) } diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 5d20d0f8..dcab45cf 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -220,9 +220,10 @@ export class Store implements LoginIndex { } /** - * Makes a project: its conversation, its place in the index, and the start screen's draft moved to it, in one commit. - * Its folder is made inside that commit, before anything is kept, so a folder that cannot be made keeps nothing and - * the draft stays on the start screen. A crash after the folder leaves an empty folder, which the next id skips. + * Makes a project: its conversation, its place in the index, and the start screen's draft moved to it when that + * draft holds the project's first message, in one commit. A start screen draft with other text is the person's next + * idea, so it stays. The folder is made inside that commit, before anything is kept, so a folder that cannot be made + * keeps nothing. A crash after the folder leaves an empty folder, which the next id skips. */ async createProject(title: string): Promise { const firstLine = (title.split('\n')[0] ?? '').trim().slice(0, 80) || 'New part' @@ -242,7 +243,7 @@ export class Store implements LoginIndex { index.projects.push(created) const { drafts } = await tx.doc(DraftsDoc, this.root.id) const draft = drafts.new - if (draft !== undefined) { + if (draft !== undefined && draft.text.trim() === title.trim()) { drafts[id] = draft delete drafts.new } diff --git a/sidecar/test/store.test.ts b/sidecar/test/store.test.ts index 6aed12f5..e774e091 100644 --- a/sidecar/test/store.test.ts +++ b/sidecar/test/store.test.ts @@ -125,6 +125,16 @@ describe('app state', () => { await app.quit() }) + test('a project made from the start screen leaves a start screen draft that holds other text where it is', async () => { + const { data, projects } = scratch() + const app = spawnSidecar(data, projects) + await app.next() + await app.call('drafts.set', { key: 'new', text: 'A shelf bracket', attachments: [] }) + await app.call('projects.create', { title: 'A knob' }) + expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ new: { text: 'A shelf bracket', attachments: [] } }) + await app.quit() + }) + test('a project made from the start screen takes the start screen draft with it', async () => { const { data, projects } = scratch() const app = spawnSidecar(data, projects) diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index 44ed21b5..916a2bb8 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -130,8 +130,10 @@ describe('the update gate', () => { const installing = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) installing.gate.installing = true await installing.until((event) => event.type === 'ready') - expect(installing.gate.requests).toEqual([true]) await Bun.sleep(1_500) + // It asks for the pass again each second while the update installs, and never starts the work without it. + expect(installing.gate.requests.length).toBeGreaterThan(0) + expect(installing.gate.requests.every((busy) => busy)).toBe(true) expect(existsSync(started)).toBe(false) await installing.quit() @@ -182,6 +184,45 @@ describe('the update gate', () => { await app.quit() }, 40_000) + /** A turn whose Astra tool signed in while an update installs, so it waits for the pass. */ + async function waitingForPass() { + const where = scratch() + const keychain = new Map() + const app = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.send', { project, requestId: 'r1', text: 'go', attachments: [] }) + await app.until((event) => event.type === 'chat' && event.waiting !== null) + app.gate.installing = true + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + await Bun.sleep(1_500) + return { where, keychain, app, project } + } + + test('a stop ends a tool that waits for the pass after its sign-in', async () => { + const { where, app, project } = await waitingForPass() + await app.call('chat.stop', { project }) + await app.until((event) => event.type === 'chat' && event.project === project && event.running === false, 5_000) + expect(existsSync(join(where.projects, project, 'astra.txt'))).toBe(false) + await app.quit() + }, 40_000) + + test('a quit leaves a tool that waits for the pass unfinished, so it goes on after the restart', async () => { + const { where, keychain, app, project } = await waitingForPass() + const quit = await Promise.race([app.quit().then(() => 'exited'), Bun.sleep(5_000).then(() => 'still running')]) + expect(quit).toBe('exited') + expect(existsSync(join(where.projects, project, 'astra.txt'))).toBe(false) + + const after = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) + await after.next() + const done = (entries: ChatEntry[]) => entries.some((entry) => entry.kind === 'assistant' && entry.text === 'parent saw: astra drew it') + if (!done(((await after.call('chat.open', { project })) as ChatView).entries)) { + await after.until((event) => event.type === 'chat' && done(event.entries as ChatEntry[]), 30_000) + } + await after.quit() + }, 60_000) + test('gives the pass back while a turn waits on a sign-in, and takes it again when the turn goes on', async () => { const where = scratch() const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra', env: where.env }) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 99060328..c1664a6d 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -236,14 +236,38 @@ describe('StudioApp', () => { fireEvent.click(screen.getByTestId('send')) await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) type('second message') + // The pause saves it as the start screen's draft before the project is there. + await waitFor(() => expect(sidecarCalls('drafts.set')).toContainEqual({ key: 'new', text: 'second message', attachments: [] })) await act(async () => finishCreate(projectA)) await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + expect(sidecarCalls('drafts.set')).toContainEqual({ key: projectA.id, text: 'second message', attachments: [] }) + expect(sidecarCalls('drafts.set').at(-1)).toEqual({ key: 'new', text: '', attachments: [] }) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) fireEvent.click(screen.getByText('Project A')) await waitFor(() => expect(prompt().value).toBe('second message')) }) + it('leaves a start screen draft typed after leaving and coming back alone when the earlier project is made', async () => { + let finishCreate: (project: unknown) => void = () => {} + mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) + render() + await screen.findByText('Project B') + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + fireEvent.click(screen.getByTestId('new-project')) + type('A shelf bracket') + await act(async () => finishCreate(projectA)) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + expect(prompt().value).toBe('A shelf bracket') + // Once typing pauses, the bracket is the start screen's draft, never the new project's. + await waitFor(() => expect(sidecarCalls('drafts.set')).toContainEqual({ key: 'new', text: 'A shelf bracket', attachments: [] })) + expect(sidecarCalls('drafts.set')).not.toContainEqual(expect.objectContaining({ key: projectA.id, text: 'A shelf bracket' })) + }) + it('leaves the start screen empty when the person cleared it before the saved state arrived', async () => { let finishState: (state: unknown) => void = () => {} mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 30bab958..d9d07b6a 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -75,10 +75,13 @@ export default function StudioApp() { const pending = useRef(new Map()) /** Whether the person typed on the start screen, so the saved draft no longer fills it. */ const typedOnStart = useRef(false) + /** The composer's text as it is now, for a send that finishes after the person typed more. */ + const composerText = useRef('') /** The project whose chat view is loaded. Chat changes for the open project wait in `early` until it is. */ const loaded = useRef(null) const early = useRef([]) + composerText.current = text const items = useMemo(() => buildChat(view?.entries ?? []), [view?.entries]) const running = view?.running ?? false @@ -252,13 +255,22 @@ export default function StudioApp() { if (!project) { const created = await sidecar('projects.create', { title: message || 'New part' }) project = created - // The sidecar moved the start screen's draft to the new project in the same commit. + // The sidecar moved the start screen's draft to the new project in the same commit, when it held this message. const { [START]: started, ...rest } = drafts.current - drafts.current = started === undefined ? rest : { ...rest, [created.id]: started } - // Text typed while the project was made is its draft too. - if (unsaved.current?.key === START) unsaved.current.key = created.id + if (started?.text.trim() === message) drafts.current = { ...rest, [created.id]: started } setProjects((prev) => [created, ...prev]) if (loadRequest.current === request) { + // The person stayed on the screen this was sent from, so text typed while the project was made follows them + // to it, and the start screen is empty again. + const typed = composerText.current + if (typed.trim() !== message) { + if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) + unsaved.current = null + drafts.current = { ...drafts.current, [created.id]: { text: typed }, [START]: { text: '' } } + for (const [key, value] of [[created.id, typed], [START, '']]) { + void sidecar('drafts.set', { key, text: value, attachments: [] }).catch((err: unknown) => setError(String(err))) + } + } show(created, true) request = loadRequest.current await load(created.id, request) From 77d3a53aad7af83448714166421cb57d1229757c Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 01:19:40 -0600 Subject: [PATCH 07/59] fix(sidecar): a turn waiting on Astra's sign-in gives the pass back when a tool beside it already finished A finished child is gone from the task graph, and the pass counted a task that waited on a gone child as about to go on. So a turn that ran `read` beside `astra` held the pass for as long as Astra waited on a sign-in. A waiting task is now idle when it still waits on a live child and every live child is idle. Only one whose children all finished is about to go on. TLC: all nine verdicts match, and all ten mutants fail as they must. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 2 +- sidecar/src/busy.ts | 9 ++++++--- sidecar/test/fixtures/scripted-sidecar.ts | 8 ++++++++ sidecar/test/switch.test.ts | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index ec58281e..a9e5e21a 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -5,6 +5,6 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs cf930114c31d3a191eb461ad23a7afb95162277f710a417ea73cc88d501d8e4e src-tauri/src/sidecar.rs -015742ffd6bd28e2d1af8889c15a01f378289df09efc88726446808b17b6a3a4 sidecar/src/busy.ts +22ab51147bba235e65e4bc9737dfcc2f60046039661043d3e5c4ed68a75c8ccc sidecar/src/busy.ts d87ab72442d48f33334e17e6ebdfd1660f7b0753ee51f1703b8ac6f3706149f3 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts diff --git a/sidecar/src/busy.ts b/sidecar/src/busy.ts index cab897f2..a07ad457 100644 --- a/sidecar/src/busy.ts +++ b/sidecar/src/busy.ts @@ -31,9 +31,12 @@ function working(graph: TaskGraph, parked: ReadonlySet): boolean { const idle = (id: string): boolean => { if (parked.has(id)) return true const task = graph.tasks[id] - if (task === undefined) return false - if (task.blocked !== undefined) return true - return task.state.status === 'waiting' && task.state.on.every((child) => idle(String(child))) + if (task === undefined || task.blocked !== undefined) return task !== undefined + if (task.state.status !== 'waiting') return false + // A finished child is gone from the graph. A task whose children all finished is about to go on, and one that + // still waits on a live child is idle when every live child is. + const live = task.state.on.map(String).filter((child) => graph.tasks[child] !== undefined) + return live.length > 0 && live.every(idle) } return Object.keys(graph.tasks).some((id) => !idle(id)) } diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index 0001e5f2..d6ea1191 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -44,6 +44,14 @@ const scripts: Record) => Ass if (results.length === 0) return call('astra', { task: 'Draw it as an HTML page.' }) return fauxAssistantMessage(fauxText(`parent saw: ${resultText(results[0])}`)) }, + /** Reads a file and hands Astra a task in the same step, then repeats what Astra answered. */ + 'astra-and-read': (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) { + return fauxAssistantMessage([fauxToolCall('read', { path: 'note.txt' }), fauxToolCall('astra', { task: 'Draw it as an HTML page.' })], { stopReason: 'toolUse' }) + } + return fauxAssistantMessage(fauxText('both done')) + }, /** Shows the command's environment: whether the sidecar's own variables reach it, and its operation id. */ env: (messages) => { const { results } = sinceUser(messages) diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index 916a2bb8..186be586 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -223,6 +223,21 @@ describe('the update gate', () => { await after.quit() }, 60_000) + test('gives the pass back while Astra waits on a sign-in, even when a tool beside it already finished', async () => { + const where = scratch() + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra-and-read', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + writeFileSync(join(where.projects, project, 'note.txt'), 'a note') + await app.call('chat.send', { project, requestId: 'r1', text: 'go', attachments: [] }) + await app.until((event) => event.type === 'chat' && event.waiting !== null) + const deadline = Date.now() + 5_000 + while (app.gate.requests.at(-1) !== false && Date.now() < deadline) await Bun.sleep(25) + expect(app.gate.requests.at(-1)).toBe(false) + await app.quit() + }, 30_000) + test('gives the pass back while a turn waits on a sign-in, and takes it again when the turn goes on', async () => { const where = scratch() const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra', env: where.env }) From e0e39ec9b8cab8ea43b5ab3c899b40bf9210c2a5 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 01:21:48 -0600 Subject: [PATCH 08/59] test(studio): the draft test reads its last save without Array.at, which the window's lib leaves out Co-Authored-By: Claude Opus 5.5 --- src/studio/StudioApp.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index c1664a6d..7a90d28d 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -241,7 +241,8 @@ describe('StudioApp', () => { await act(async () => finishCreate(projectA)) await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) expect(sidecarCalls('drafts.set')).toContainEqual({ key: projectA.id, text: 'second message', attachments: [] }) - expect(sidecarCalls('drafts.set').at(-1)).toEqual({ key: 'new', text: '', attachments: [] }) + const saves = sidecarCalls('drafts.set') + expect(saves[saves.length - 1]).toEqual({ key: 'new', text: '', attachments: [] }) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) fireEvent.click(screen.getByText('Project A')) From 4075bb8a890b1fb1f288fdff5b018354beb80b47 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 01:29:15 -0600 Subject: [PATCH 09/59] fix(studio): a sent message never comes back as the start screen's draft Text typed while a project was made, then taken back before its pause, left a save waiting for the start screen that held the sent message. Making the project skipped the move, as the text matched the message, and the waiting save put the message back on the start screen. Once the person stayed on that screen, the waiting save now goes with the move or goes away. Co-Authored-By: Claude Opus 5.5 --- src/studio/StudioApp.test.tsx | 19 +++++++++++++++++++ src/studio/StudioApp.tsx | 6 +++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 7a90d28d..6d19feaf 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -249,6 +249,25 @@ describe('StudioApp', () => { await waitFor(() => expect(prompt().value).toBe('second message')) }) + it('never puts a sent message back on the start screen when text typed while the project was made was taken back', async () => { + let finishCreate: (project: unknown) => void = () => {} + mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) + render() + await screen.findByText('Project B') + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) + type('A clip for my desk, and') + type('A clip for my desk') + await act(async () => finishCreate(projectA)) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await new Promise((resolve) => setTimeout(resolve, 600)) + const restored = sidecarCalls('drafts.set').filter((save) => save.key === 'new' && save.text === 'A clip for my desk') + expect(restored).toHaveLength(1) + fireEvent.click(screen.getByTestId('new-project')) + expect(prompt().value).toBe('') + }) + it('leaves a start screen draft typed after leaving and coming back alone when the earlier project is made', async () => { let finishCreate: (project: unknown) => void = () => {} mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index d9d07b6a..a485b0c3 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -261,11 +261,11 @@ export default function StudioApp() { setProjects((prev) => [created, ...prev]) if (loadRequest.current === request) { // The person stayed on the screen this was sent from, so text typed while the project was made follows them - // to it, and the start screen is empty again. + // to it, and the start screen is empty again. A save still waiting belongs to that screen, so it goes too. const typed = composerText.current + if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) + unsaved.current = null if (typed.trim() !== message) { - if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) - unsaved.current = null drafts.current = { ...drafts.current, [created.id]: { text: typed }, [START]: { text: '' } } for (const [key, value] of [[created.id, typed], [START, '']]) { void sidecar('drafts.set', { key, text: value, attachments: [] }).catch((err: unknown) => setError(String(err))) From 52d2f28f4fc9161a61bb6886df83134d718cace3 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 03:04:00 -0600 Subject: [PATCH 10/59] fix(studio): the window's engine status listens before it asks, as the sidecar PR's does Carries #197's review fix into the window this PR rewrites: the listener is in place before the ping, and a ping that answers late never hides a newer engine event. The shell deadline from #197 moved into sidecar.rs with login_path, so its hash is recorded again. TLC: all nine verdicts match, and all ten mutants fail. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 2 +- src/studio/StudioApp.test.tsx | 10 ++++++++++ src/studio/StudioApp.tsx | 12 +++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index a9e5e21a..9a3f2643 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -4,7 +4,7 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ 581b0e828a547528a87785cd366c6e57252dea85823cc1402fcaf7b113a2255c src-tauri/src/printer/mod.rs dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs -cf930114c31d3a191eb461ad23a7afb95162277f710a417ea73cc88d501d8e4e src-tauri/src/sidecar.rs +4d6b87b3ac86a66d6379d13562d75c860ddcc69a62c698a62227f764b4c5be43 src-tauri/src/sidecar.rs 22ab51147bba235e65e4bc9737dfcc2f60046039661043d3e5c4ed68a75c8ccc sidecar/src/busy.ts d87ab72442d48f33334e17e6ebdfd1660f7b0753ee51f1703b8ac6f3706149f3 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 6d19feaf..106da1b0 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -79,6 +79,16 @@ describe('StudioApp', () => { expect((await screen.findByText('Engine 1.2.3')).dataset.testid).toBe('engine') }) + it('keeps an engine stop that arrives while its version is still on the way', async () => { + let answerPing: (value: unknown) => void = () => {} + mockSidecar({ 'app.ping': () => new Promise((resolve) => (answerPing = resolve)) }) + render() + await waitFor(() => expect(sidecarCalls('app.ping')).toHaveLength(1)) + await emit({ type: 'engine', state: 'stopped', detail: 'The engine stopped.' }) + await act(async () => answerPing({ version: '1.2.3' })) + expect(screen.getByTestId('engine').textContent).toBe('The engine stopped.') + }) + it('leaves the project the person moved to alone when a slow send from the start screen finishes', async () => { let finishCreate: (project: unknown) => void = () => {} mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index a485b0c3..e643a32d 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -91,9 +91,6 @@ export default function StudioApp() { }, []) useEffect(() => { - sidecar('app.ping', {}) - .then(({ version }) => setEngine(`Engine ${version}`)) - .catch((err: unknown) => setEngine(String(err))) sidecar('state.get', {}) .then((state) => { // Drafts saved in this window since are newer, and text already typed stays. @@ -102,12 +99,17 @@ export default function StudioApp() { if (currentId.current === null && !typedOnStart.current) setText(drafts.current[START]?.text ?? '') }) .catch((err: unknown) => setError(String(err))) + // The listener is in place before the ping, so no engine change is missed, and a ping that answers late never + // hides one. + let engineChanged = false const unlisten = onSidecarEvent((event) => { switch (event.type) { case 'ready': + engineChanged = true setEngine(`Engine ${event.version}`) break case 'engine': + engineChanged = true setEngine(event.state === 'stopped' ? event.detail : 'Engine restarting') break case 'chat': @@ -132,6 +134,10 @@ export default function StudioApp() { break } }) + unlisten + .then(() => sidecar('app.ping', {})) + .then(({ version }) => engineChanged || setEngine(`Engine ${version}`)) + .catch((err: unknown) => engineChanged || setEngine(String(err))) return () => { void unlisten.then((stop) => stop()) } From 613b27b3a09bcc975ba7bd1e8c900a6c8323b318 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 03:25:52 -0600 Subject: [PATCH 11/59] fix(sidecar): a projects path that is not a folder holds no old chats, and the sidecar still starts The store PR's new test puts a file where the projects folder goes. The import of old chats listed that path and threw, so the sidecar ended before it answered. A projects path that is not a folder now has nothing to import. The attachment check from #200 changed chat.ts, so its hash is recorded again. TLC: all nine verdicts match, and all ten mutants fail as they must. Co-Authored-By: Claude Opus 5.5 --- .../formal-verify-materialize-3d/models/sources.sha256 | 2 +- sidecar/src/legacy.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 9a3f2643..6031a316 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs 4d6b87b3ac86a66d6379d13562d75c860ddcc69a62c698a62227f764b4c5be43 src-tauri/src/sidecar.rs 22ab51147bba235e65e4bc9737dfcc2f60046039661043d3e5c4ed68a75c8ccc sidecar/src/busy.ts -d87ab72442d48f33334e17e6ebdfd1660f7b0753ee51f1703b8ac6f3706149f3 sidecar/src/chat.ts +becc8c18c3918da0aa673da8c492db2e6e68352cd650efa2312560fb624c8282 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts diff --git a/sidecar/src/legacy.ts b/sidecar/src/legacy.ts index a3dfc84a..02bfdb7f 100644 --- a/sidecar/src/legacy.ts +++ b/sidecar/src/legacy.ts @@ -3,7 +3,7 @@ * `.m3d/transcript.jsonl`, Claude Code stream-json events plus the app's own `m3d_*` events. Each one is imported * once, as display entries, and its files stay as they are. */ -import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import type { ImportedEntry } from './protocol' @@ -82,7 +82,8 @@ export function readTranscript(text: string): ImportedEntry[] { * not read. A project whose files cannot be read is skipped and named on stderr, as the old app skipped it. */ export function legacyProjects(projectsDir: string, known: ReadonlySet): LegacyProject[] { - if (!existsSync(projectsDir)) return [] + // No projects folder, or something else in its place, holds no old chats. + if (!existsSync(projectsDir) || !statSync(projectsDir).isDirectory()) return [] return readdirSync(projectsDir, { withFileTypes: true }).flatMap((dir) => { const meta = join(projectsDir, dir.name, '.m3d') if (known.has(dir.name) || !dir.isDirectory() || !existsSync(join(meta, 'project.json')) || !existsSync(join(meta, 'transcript.jsonl'))) return [] From af515086f69d10b8246ce8b759556648df858cf6 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 03:35:02 -0600 Subject: [PATCH 12/59] chore(formal): the update models still describe the sidecar after #200's picture fix The picture conversion in chat.ts moved out of the project. It does not touch the update pass. TLC: all nine verdicts match, and all ten mutants fail as they must. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 6031a316..097cbb1c 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs 4d6b87b3ac86a66d6379d13562d75c860ddcc69a62c698a62227f764b4c5be43 src-tauri/src/sidecar.rs 22ab51147bba235e65e4bc9737dfcc2f60046039661043d3e5c4ed68a75c8ccc sidecar/src/busy.ts -becc8c18c3918da0aa673da8c492db2e6e68352cd650efa2312560fb624c8282 sidecar/src/chat.ts +3efa2d0f273d25edd0d14261e0f4366346e85275837791ac1784629f7a7c4fb3 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts From 4421df6f12b1ef6fb9d47181c8560bc3f0104d6b Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 11:15:48 -0600 Subject: [PATCH 13/59] fix(sidecar): a turn that goes on with a newer login takes the update pass #200 let a failure's read go on at once when a newer login was saved, by submitting the continue in its own commit. On this branch every continue must hold the update pass, so an update never restarts the app mid- turn. The read now records the wait and says a newer login is there, and the continue goes through the pass. A continue for a sign-in goes on only while the turn still waits on one, so a sign-in and a read that cross let it go once. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 40 +++++++++++++++++++----------------- sidecar/test/waiting.test.ts | 23 +++++++++++++++++++++ src-tauri/src/sidecar.rs | 2 +- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index e7657601..ae337e82 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -147,7 +147,11 @@ export class Chats { const recorded = (await this.#o.store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { // The waiting document is not part of the view a watch follows, so its commit wakes no one: read it here. - await this.#read(conversation, state.failure, state.turn) + // A login saved since the turn went out lets it go on now: the sign-in that saved it found no wait to let go. + // If an update is installing, the pass is refused and the wait stays, to go on after the restart. + if (await this.#read(conversation, state.failure, state.turn)) { + await this.#continue(project, 'login').catch((error: unknown) => console.error(`going on with a newer login failed: ${String(error)}`)) + } state = await this.#state(conversation) } const entries = await this.#entries(conversation, this.#sent.get(project) ?? 0) @@ -157,11 +161,11 @@ export class Chats { /** * Reads why a run stopped: a provider with no login, or a refused one, waits for a sign-in; anything else for a retry. - * A login missing or refused when a newer one was saved since the turn went out goes on with the newer one at once, - * since the sign-in that saved it found no wait to let go. The login read here may be older than that save, so only - * the save count, read in the commit, decides. A turn from another version of the app is left to wait. + * Resolves true when the login was missing or refused but a newer one was saved since the turn went out, so the + * wait can be let go at once. The login read here may be older than that save, so only the save count, read in the + * commit, decides. A turn from another version of the app is left to wait. */ - async #read(conversation: Conversation, failure: RunFailure, turn: Turn | null): Promise { + async #read(conversation: Conversation, failure: RunFailure, turn: Turn | null): Promise { const { models, store, version } = this.#o const provider = turn?.model.provider ?? 'anthropic' const name = models.getProvider(provider)?.name ?? provider @@ -172,21 +176,16 @@ export class Chats { login ? { kind: 'login', provider, name, fix: `Sign in to ${name}. The message picks up where it stopped.` } : { kind: 'error', detail, fix: 'Try again' } - await store.harness.commit(async (tx) => { + return store.harness.commit(async (tx) => { // A send that came after this failure started a new turn, which this failure must not hold. - if ((await tx.doc(TurnDoc, conversation.id)).turn?.requestId !== turn?.requestId) return + if ((await tx.doc(TurnDoc, conversation.id)).turn?.requestId !== turn?.requestId) return false const record = await tx.doc(WaitingDoc, conversation.id) record.failure = failure.taskId record.tool = false + record.waiting = waiting // Read in this commit, so a sign-in either lands before it and is seen here, or after it and finds this wait. const saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[provider] ?? 0 - if (login && record.saves !== null && saves > record.saves && turn?.app === version) { - await conversation.submit(tx, { type: 'continue' }) - record.waiting = null - record.saves = saves - return - } - record.waiting = waiting + return login && record.saves !== null && saves > record.saves && turn?.app === version }, context) } @@ -237,15 +236,18 @@ export class Chats { /** * Goes on from where the turn stopped, with the model, tools, and instructions it started with, and clears what it - * waited on. The turn records the app version that goes on with it. + * waited on. The turn records the app version that goes on with it. With `only: 'login'`, it goes on only while the + * turn still waits on a sign-in, so when two paths let one wait go, as a sign-in and the read of a failure that + * crossed it, the turn goes on once. */ - async #continue(project: string): Promise { + async #continue(project: string, only?: 'login'): Promise { const { store, version } = this.#o const conversation = await this.#conversation(project) await this.#o.busy.during(() => store.harness.commit(async (tx) => { - await conversation.submit(tx, { type: 'continue' }) const waiting = await tx.doc(WaitingDoc, conversation.id) + if (only === 'login' && waiting.waiting?.kind !== 'login') return + await conversation.submit(tx, { type: 'continue' }) waiting.waiting = null const { turn } = await tx.doc(TurnDoc, conversation.id) if (turn !== null) { @@ -356,7 +358,7 @@ export class Chats { await this.#outdated(conversation) await this.#show(project.id) } else { - await this.#continue(project.id) + await this.#continue(project.id, 'login') } } } @@ -405,7 +407,7 @@ export class Chats { if (record === undefined || record.waiting?.kind !== 'login' || record.tool || running) continue const waiting = record.waiting if (state.turn !== null && state.turn.app !== version) await this.#outdated(conversation.id) - else if (await this.#savedSince(record, waiting.provider)) await this.#continue(project.id) + else if (await this.#savedSince(record, waiting.provider)) await this.#continue(project.id, 'login') } } diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts index af471d6f..758fd5a1 100644 --- a/sidecar/test/waiting.test.ts +++ b/sidecar/test/waiting.test.ts @@ -100,6 +100,29 @@ describe('a turn whose provider is not signed in', () => { proxy.stop() }, 20_000) + test('a turn refused after a newer login was saved goes on with it only with the update pass', async () => { + const release = Promise.withResolvers() + const proxy = anthropicProxy('Hello after the update', { token: 'old-token', release: release.promise }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) + await app.next() + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + while (proxy.seen.length === 0) await Bun.sleep(10) + await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + // An update starts installing while the old request is still out. + app.gate.installing = true + release.resolve() + const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) + expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'anthropic' }) + await Bun.sleep(500) + expect(proxy.seen).toHaveLength(1) + await app.quit() + proxy.stop() + }, 20_000) + test('a turn that failed with no login goes on when one is saved while the failure is read', async () => { const proxy = anthropicProxy('Hello after the slow read') const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index b375a254..a943af9c 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -6,10 +6,10 @@ //! (`sidecar/src/protocol.ts`). Rust never opens the sidecar's store: it only names its folder. use std::collections::HashMap; +use std::future::Future; use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::future::Future; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; From 922604a8cd09cb977eaac719ec17b0bd2c66b8fc Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 11:17:24 -0600 Subject: [PATCH 14/59] chore(formal): the update models still describe the sidecar after the rebase onto #200 #200's slice once per operation and the ready-event link do not touch the gate, and every continue still takes the update pass through Busy.during, now including a turn that goes on with a newer login. TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 097cbb1c..f7f6e3c3 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -4,7 +4,7 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ 581b0e828a547528a87785cd366c6e57252dea85823cc1402fcaf7b113a2255c src-tauri/src/printer/mod.rs dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs -4d6b87b3ac86a66d6379d13562d75c860ddcc69a62c698a62227f764b4c5be43 src-tauri/src/sidecar.rs +ea5daf74be2f4cf79b5e13ce9e8bcc465b42bacf807a313c33d0a1c7f40fef5b src-tauri/src/sidecar.rs 22ab51147bba235e65e4bc9737dfcc2f60046039661043d3e5c4ed68a75c8ccc sidecar/src/busy.ts -3efa2d0f273d25edd0d14261e0f4366346e85275837791ac1784629f7a7c4fb3 sidecar/src/chat.ts +1991eb7368f6a11cabb2498fd9ac4386cff2d953136d343a589caf14103ce7c8 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts From 9dd65079c4160cc3fd9e9ade406992a7579713df Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 11:38:02 -0600 Subject: [PATCH 15/59] fix(sidecar): a turn a sign-in lets go waits out an installing update instead of being left waiting A turn let go by a sign-in, or by a newer login a failure's read found, asked for the update pass once. While an update installed the pass was refused, and if the install then failed nothing asked again, so the turn showed "Sign in" with a working login saved. Such a turn now asks for the pass each second, as a tool back from a sign-in does, and goes on once the gate opens. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/busy.ts | 16 ++++++++++++++++ sidecar/src/chat.ts | 17 ++++++++++------- sidecar/test/waiting.test.ts | 12 ++++++++++-- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/sidecar/src/busy.ts b/sidecar/src/busy.ts index a07ad457..00afb974 100644 --- a/sidecar/src/busy.ts +++ b/sidecar/src/busy.ts @@ -137,6 +137,22 @@ export class Busy { } } + /** + * `during` for work that goes on by itself, such as a turn a sign-in lets go: while an update installs it asks again + * each second instead of failing, so an install that fails does not leave the work waiting. Resolves undefined, with + * nothing committed, once the sidecar closes. An install that succeeds restarts the app, and the work goes on then. + */ + async duringWhenAllowed(commit: () => Promise): Promise { + this.#sends += 1 + try { + if (!(await this.pass())) return undefined + return await commit() + } finally { + this.#sends -= 1 + await this.#follow() + } + } + /** * Gives the pass back while the tool `task` waits on `wait` for a sign-in. Once the wait ends, the tool goes on only * with the pass, so an update that started installing meanwhile holds it until the app restarts or the install fails. diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index ae337e82..d3cf7a49 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -147,10 +147,11 @@ export class Chats { const recorded = (await this.#o.store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { // The waiting document is not part of the view a watch follows, so its commit wakes no one: read it here. - // A login saved since the turn went out lets it go on now: the sign-in that saved it found no wait to let go. - // If an update is installing, the pass is refused and the wait stays, to go on after the restart. + // A login saved since the turn went out lets it go on now: the sign-in that saved it found no wait to let go. It + // waits out an update that is installing without holding up this chat's other changes. The wait stays recorded + // until it goes on, so a failure here leaves the fix shown. if (await this.#read(conversation, state.failure, state.turn)) { - await this.#continue(project, 'login').catch((error: unknown) => console.error(`going on with a newer login failed: ${String(error)}`)) + void this.#continue(project, 'login').catch((error: unknown) => console.error(`going on with a newer login failed: ${String(error)}`)) } state = await this.#state(conversation) } @@ -238,12 +239,13 @@ export class Chats { * Goes on from where the turn stopped, with the model, tools, and instructions it started with, and clears what it * waited on. The turn records the app version that goes on with it. With `only: 'login'`, it goes on only while the * turn still waits on a sign-in, so when two paths let one wait go, as a sign-in and the read of a failure that - * crossed it, the turn goes on once. + * crossed it, the turn goes on once. Such a turn goes on by itself, so it waits for the pass while an update + * installs, instead of failing and being left waiting when the install fails. */ async #continue(project: string, only?: 'login'): Promise { const { store, version } = this.#o const conversation = await this.#conversation(project) - await this.#o.busy.during(() => + const commit = () => store.harness.commit(async (tx) => { const waiting = await tx.doc(WaitingDoc, conversation.id) if (only === 'login' && waiting.waiting?.kind !== 'login') return @@ -254,8 +256,9 @@ export class Chats { turn.app = version waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[turn.model.provider] ?? 0 } - }, context), - ) + }, context) + if (only === 'login') await this.#o.busy.duringWhenAllowed(commit) + else await this.#o.busy.during(commit) await this.#watch(project, conversation) } diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts index 758fd5a1..30496a10 100644 --- a/sidecar/test/waiting.test.ts +++ b/sidecar/test/waiting.test.ts @@ -100,7 +100,7 @@ describe('a turn whose provider is not signed in', () => { proxy.stop() }, 20_000) - test('a turn refused after a newer login was saved goes on with it only with the update pass', async () => { + test('a turn refused after a newer login was saved goes on with it only with the update pass, and once a failed install opens the gate', async () => { const release = Promise.withResolvers() const proxy = anthropicProxy('Hello after the update', { token: 'old-token', release: release.promise }) const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) @@ -119,9 +119,17 @@ describe('a turn whose provider is not signed in', () => { expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'anthropic' }) await Bun.sleep(500) expect(proxy.seen).toHaveLength(1) + // The install fails and the gate opens: the turn goes on by itself, with no new sign-in. + app.gate.installing = false + const answered = await app.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect((answered.entries as ChatEntry[]).find((entry) => entry.kind === 'assistant')).toMatchObject({ text: 'Hello after the update' }) + expect(proxy.seen.map((call) => call.authorization)).toEqual(['Bearer old-token', 'Bearer new-token']) await app.quit() proxy.stop() - }, 20_000) + }, 30_000) test('a turn that failed with no login goes on when one is saved while the failure is read', async () => { const proxy = anthropicProxy('Hello after the slow read') From f0cd331e20a3314375d359cac1e7ad58869e49fe Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 11:38:02 -0600 Subject: [PATCH 16/59] fix(sidecar): a slice that outlives its sidecar holds the update off until it ends Rust's slice goes on after the sidecar that asked for it crashes, so a resumed turn can take its result. It held the update off only through that sidecar run's pass, which ends with the run, so an update could restart the app mid-slice. Each slice now takes a pass of its own, as the app's own slicing does, and none starts while an update installs. The update models name the new caller, and TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../formal-verify-materialize-3d/SKILL.md | 2 +- .../models/sources.sha256 | 6 +-- src-tauri/src/sidecar.rs | 47 +++++++++++++++++-- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/SKILL.md b/.agents/skills/formal-verify-materialize-3d/SKILL.md index 6b415394..09b3e1a3 100644 --- a/.agents/skills/formal-verify-materialize-3d/SKILL.md +++ b/.agents/skills/formal-verify-materialize-3d/SKILL.md @@ -78,7 +78,7 @@ Then read each covered function that changed. Also look for new code that reache | Model action | Code | |---|---| -| `Enter`, `Leave` | `BuildGate::enter` and `Drop for Running` in `build_gate.rs`. Callers: `build_as` in `pipeline.rs`, `slice_model` in `commands.rs`. For the sidecar's chat work, `BuildGate::enter_owned` through `take_pass` in `set_busy` (`sidecar.rs`), which `sidecar/src/busy.ts` asks for before a send commits (`Busy.during` in `sidecar/src/chat.ts`) and before work a crash cut off resumes (`busy.watch` in `sidecar/src/app.ts`), and `Drop for OwnedRunning` when it gives the pass back once no task is pending or running, after its last commit. A task that waits, sleeps, or holds a sign-in wait (`Busy.parked`) is not work: it resumes after a restart. `set_busy` refuses a request from a sidecar run that ended. | +| `Enter`, `Leave` | `BuildGate::enter` and `Drop for Running` in `build_gate.rs`. Callers: `build_as` in `pipeline.rs`, `slice_model` in `commands.rs`. For the sidecar's chat work, `BuildGate::enter_owned` through `take_pass` in `set_busy` (`sidecar.rs`), which `sidecar/src/busy.ts` asks for before a send commits (`Busy.during` in `sidecar/src/chat.ts`) and before work a crash cut off resumes (`busy.watch` in `sidecar/src/app.ts`), and `Drop for OwnedRunning` when it gives the pass back once no task is pending or running, after its last commit. A task that waits, sleeps, or holds a sign-in wait (`Busy.parked`) is not work: it resumes after a restart. `set_busy` refuses a request from a sidecar run that ended. A slice the sidecar asks for takes a pass of its own (`with_own_pass` in `sidecar.rs`), since it can outlive the sidecar run that asked for it. A turn a sign-in lets go waits for the pass while an update installs (`Busy.duringWhenAllowed`). | | `Downloaded`, `Top`, `CheckAndClose`, `CheckBusy`, `Wake` | `BuildGate::close_when_idle`. Its `subscribe`, `borrow_and_update`, one `send_if_modified`, and `changed().await`. | | `Reopen` | `Drop for Closed`, on each error return of `install_when_idle`. | | `Click`, `DownloadFails`, `BackupOk`, `BackupFails`, `InstallOk`, `InstallFails`, `Exit` | `update_install` and `install_when_idle` in `updater.rs`, then Tauri's `AppHandle::restart`, which ends in `process::exit(0)`. | diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index f7f6e3c3..80ac98ac 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -4,7 +4,7 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ 581b0e828a547528a87785cd366c6e57252dea85823cc1402fcaf7b113a2255c src-tauri/src/printer/mod.rs dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs -ea5daf74be2f4cf79b5e13ce9e8bcc465b42bacf807a313c33d0a1c7f40fef5b src-tauri/src/sidecar.rs -22ab51147bba235e65e4bc9737dfcc2f60046039661043d3e5c4ed68a75c8ccc sidecar/src/busy.ts -1991eb7368f6a11cabb2498fd9ac4386cff2d953136d343a589caf14103ce7c8 sidecar/src/chat.ts +4db78bc41b64823eaa86e8e5e7a5a989ae360573221a1186d28fe2f36e94cb67 src-tauri/src/sidecar.rs +672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts +4a6bac3950538fb5a441a6d8d64c1debddd71809c36a63836044cb626257a66d sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index a943af9c..abb82194 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -104,6 +104,17 @@ fn take_pass(state: Arc) -> Result, String> { .ok_or_else(|| "An update is installing, and the app restarts when it is done. Send this again after it opens.".to_owned()) } +/// Runs `work` under a pass of its own. Work Rust does for the sidecar can outlive the sidecar run that asked for it, +/// as a slice does after a crash, so the run's pass cannot hold the update off for it. +async fn with_own_pass(state: Arc, work: F) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let _pass = take_pass(state)?; + work().await +} + impl Default for Sidecar { fn default() -> Self { Self { @@ -561,11 +572,14 @@ async fn answer(app: &AppHandle, run: u64, method: &str, params: Value) -> Resul "slice" => { let SliceCall { operation, request } = parse(method, params)?; let cache = app.path().app_cache_dir().map_err(|e| format!("finding the app's cache folder: {e}"))?.join("bambu-presets"); - once_per_operation(&app.state::().slices, operation, || async move { - let sliced = tauri::async_runtime::spawn_blocking(move || studio::slice::slice(&request, &cache)) - .await - .map_err(|e| format!("the slice task failed: {e}"))??; - serde_json::to_value(sliced).map_err(|e| e.to_string()) + let state = app.state::>().inner().clone(); + once_per_operation(&app.state::().slices, operation, || { + with_own_pass(state, || async move { + let sliced = tauri::async_runtime::spawn_blocking(move || studio::slice::slice(&request, &cache)) + .await + .map_err(|e| format!("the slice task failed: {e}"))??; + serde_json::to_value(sliced).map_err(|e| e.to_string()) + }) }) .await } @@ -660,6 +674,29 @@ mod tests { assert_eq!(runs.load(Ordering::SeqCst), 2); } + /// A slice that outlives its sidecar run keeps the update off until it ends, and none starts while one installs. + #[tokio::test] + async fn work_under_its_own_pass_holds_an_update_until_it_ends() { + const SETTLE: Duration = Duration::from_millis(50); + let state = Arc::new(AppState::default()); + let sidecar = Sidecar::default(); + set_busy(&sidecar, &state, 0, true).expect("the sidecar takes the pass"); + let (finish, finished) = oneshot::channel::<()>(); + let slice = tokio::spawn(with_own_pass(state.clone(), || async move { + finished.await.map_err(|e| e.to_string()) + })); + tokio::time::sleep(SETTLE).await; + sidecar.run_ended(); + let mut close = std::pin::pin!(state.builds.close_when_idle(|_| {})); + assert!(tokio::time::timeout(SETTLE, &mut close).await.is_err(), "the update waits for the slice its sidecar left"); + finish.send(()).expect("the slice is still running"); + slice.await.expect("the slice task").expect("the slice"); + let closed = tokio::time::timeout(SETTLE, &mut close).await.expect("the update goes ahead once the slice ends"); + let refused = with_own_pass(state.clone(), || async { Ok(()) }).await.expect_err("no slice starts while the update installs"); + assert!(refused.contains("update is installing"), "{refused}"); + drop(closed); + } + #[test] fn a_slice_call_carries_its_operation_id() { let call: SliceCall = From 817f6ddbc3506a9ece255776930ccbadf8ca0afc Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 11:52:11 -0600 Subject: [PATCH 17/59] fix(sidecar): a sign-in lets go only the wait it was for, and never holds up the start or a quit A sign-in's resume waits for the update pass while an update installs. Two such resumes for one wait could both go on: the first went on and failed again on the same refused login, and the second let that new wait go too, sending the refused login once more. Each resume now names the run failure its wait was recorded for. At the start, a resume that waited for the pass held up the start, so a quit could not end the sidecar. Resumes now run beside the start, the chat's watch, and a quit. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 2 +- sidecar/src/chat.ts | 38 +++++++++++-------- sidecar/test/waiting.test.ts | 37 ++++++++++++++++++ 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 80ac98ac..f9e6f496 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs 4db78bc41b64823eaa86e8e5e7a5a989ae360573221a1186d28fe2f36e94cb67 src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts -4a6bac3950538fb5a441a6d8d64c1debddd71809c36a63836044cb626257a66d sidecar/src/chat.ts +67ddb84d7beb10f4a65c83677c18cddbf890c68684072f3e11a94fe705cae814 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index d3cf7a49..c98aa703 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -147,12 +147,8 @@ export class Chats { const recorded = (await this.#o.store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { // The waiting document is not part of the view a watch follows, so its commit wakes no one: read it here. - // A login saved since the turn went out lets it go on now: the sign-in that saved it found no wait to let go. It - // waits out an update that is installing without holding up this chat's other changes. The wait stays recorded - // until it goes on, so a failure here leaves the fix shown. - if (await this.#read(conversation, state.failure, state.turn)) { - void this.#continue(project, 'login').catch((error: unknown) => console.error(`going on with a newer login failed: ${String(error)}`)) - } + // A login saved since the turn went out lets it go on now: the sign-in that saved it found no wait to let go. + if (await this.#read(conversation, state.failure, state.turn)) this.#resume(project, state.failure.taskId) state = await this.#state(conversation) } const entries = await this.#entries(conversation, this.#sent.get(project) ?? 0) @@ -237,18 +233,19 @@ export class Chats { /** * Goes on from where the turn stopped, with the model, tools, and instructions it started with, and clears what it - * waited on. The turn records the app version that goes on with it. With `only: 'login'`, it goes on only while the - * turn still waits on a sign-in, so when two paths let one wait go, as a sign-in and the read of a failure that - * crossed it, the turn goes on once. Such a turn goes on by itself, so it waits for the pass while an update + * waited on. The turn records the app version that goes on with it. With `login`, the run failure a sign-in wait was + * recorded for, it goes on only while the turn still waits on that one, so when two paths let one wait go, as a + * sign-in and the read of a failure that crossed it, the turn goes on once, and a late one never lets go a newer + * wait from the same refused login. Such a turn goes on by itself, so it waits for the pass while an update * installs, instead of failing and being left waiting when the install fails. */ - async #continue(project: string, only?: 'login'): Promise { + async #continue(project: string, login?: number): Promise { const { store, version } = this.#o const conversation = await this.#conversation(project) const commit = () => store.harness.commit(async (tx) => { const waiting = await tx.doc(WaitingDoc, conversation.id) - if (only === 'login' && waiting.waiting?.kind !== 'login') return + if (login !== undefined && (waiting.waiting?.kind !== 'login' || waiting.failure !== login)) return await conversation.submit(tx, { type: 'continue' }) waiting.waiting = null const { turn } = await tx.doc(TurnDoc, conversation.id) @@ -257,11 +254,20 @@ export class Chats { waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[turn.model.provider] ?? 0 } }, context) - if (only === 'login') await this.#o.busy.duringWhenAllowed(commit) - else await this.#o.busy.during(commit) + if (login === undefined) await this.#o.busy.during(commit) + else await this.#o.busy.duringWhenAllowed(commit) await this.#watch(project, conversation) } + /** + * Lets a sign-in wait go, in the background: it may wait out an installing update, which must not hold up the chat's + * other changes, the start, or a quit. The wait stays recorded until the turn goes on, so a failure here leaves the + * fix shown. + */ + #resume(project: string, login: number): void { + this.#continue(project, login).catch((error: unknown) => console.error(`going on after a sign-in failed: ${String(error)}`)) + } + /** * Resolves once the person is signed in to `provider`, for a running tool: with `after`, signed in with a login saved * after that many saves, as the one then saved was refused. Until then it holds the turn where it is, with the fix @@ -360,8 +366,8 @@ export class Chats { if (turn !== null && turn !== undefined && turn.app !== version) { await this.#outdated(conversation) await this.#show(project.id) - } else { - await this.#continue(project.id, 'login') + } else if (waiting.failure !== null) { + this.#resume(project.id, waiting.failure) } } } @@ -410,7 +416,7 @@ export class Chats { if (record === undefined || record.waiting?.kind !== 'login' || record.tool || running) continue const waiting = record.waiting if (state.turn !== null && state.turn.app !== version) await this.#outdated(conversation.id) - else if (await this.#savedSince(record, waiting.provider)) await this.#continue(project.id, 'login') + else if (record.failure !== null && (await this.#savedSince(record, waiting.provider))) this.#resume(project.id, record.failure) } } diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts index 30496a10..d3f8eaba 100644 --- a/sidecar/test/waiting.test.ts +++ b/sidecar/test/waiting.test.ts @@ -131,6 +131,43 @@ describe('a turn whose provider is not signed in', () => { proxy.stop() }, 30_000) + test('a sign-in that waits out an installing update holds up neither the start nor a quit, and goes on later', async () => { + const proxy = anthropicProxy('Hello after the install', { token: 'old-token', release: Promise.resolve() }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const keychain = new Map() + const start = () => spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + const first = start() + await first.next() + const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } + await first.call('chat.open', { project }) + await first.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + await first.until((event) => event.type === 'chat' && event.waiting !== null) + // A new login is saved while an update installs, so the turn waits for the pass. + first.gate.installing = true + await first.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await first.quit() + // The app starts again with the update still installing, and quits at once. + const second = start() + second.gate.installing = true + await second.next() + await second.call('chat.open', { project }) + const quit = Date.now() + await second.quit() + expect(Date.now() - quit).toBeLessThan(3000) + expect(proxy.seen).toHaveLength(1) + // With the gate open, the turn goes on with the new login, with no new sign-in. + const third = start() + await third.next() + await third.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect(proxy.seen.map((call) => call.authorization)).toEqual(['Bearer old-token', 'Bearer new-token']) + await third.quit() + proxy.stop() + }, 40_000) + test('a turn that failed with no login goes on when one is saved while the failure is read', async () => { const proxy = anthropicProxy('Hello after the slow read') const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) From 1a35f90e13efff68b042343234af775061f2e85e Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 12:16:19 -0600 Subject: [PATCH 18/59] fix(sidecar): the import keeps projects with no chat, shows cut-off steps as stopped, and never stops the start From CodeRabbit's review, each with a test that failed first. A projects folder macOS keeps from the app made the import throw, so the sidecar never started. It now names the folder and imports nothing until a start can list it. An old project with a record but no chat yet is imported with an empty chat instead of dropped, and an old step with no result shows as stopped. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/legacy.ts | 36 +++++++++++++++++++++++++++++------- sidecar/test/switch.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/sidecar/src/legacy.ts b/sidecar/src/legacy.ts index 02bfdb7f..98a02235 100644 --- a/sidecar/src/legacy.ts +++ b/sidecar/src/legacy.ts @@ -1,15 +1,19 @@ /** * Chats from before the store: each project folder kept its record in `.m3d/project.json` and its chat in * `.m3d/transcript.jsonl`, Claude Code stream-json events plus the app's own `m3d_*` events. Each one is imported - * once, as display entries, and its files stay as they are. + * once, as display entries, and its files stay as they are. The old `m3d_files` cards are not kept: the Files tab lists + * every file, and the preview opens the newest. */ -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import type { ImportedEntry } from './protocol' /** How much of an old tool result the chat keeps. */ const RESULT_LIMIT = 4_000 +/** What a step shows when the chat ended before its result came back, as when the old app quit mid-step. */ +const CUT_OFF = 'It stopped before it finished.' + export type LegacyProject = { id: string; title: string; createdMs: number; entries: ImportedEntry[] } type Block = { type: string; text?: string; id?: string; name?: string; input?: Record; tool_use_id?: string; content?: unknown; is_error?: boolean } @@ -31,6 +35,7 @@ function summary(input: Record): string { export function readTranscript(text: string): ImportedEntry[] { const entries: ImportedEntry[] = [] const tools = new Map>() + const answered = new Set() for (const line of text.split('\n')) { if (line.trim() === '') continue let parsed: unknown @@ -65,6 +70,7 @@ export function readTranscript(text: string): ImportedEntry[] { for (const block of blocks) { const tool = block.type === 'tool_result' && block.tool_use_id !== undefined ? tools.get(block.tool_use_id) : undefined if (tool === undefined) continue + answered.add(block.tool_use_id ?? '') tool.result = resultText(block.content).slice(-RESULT_LIMIT) tool.failed = block.is_error === true } @@ -74,19 +80,35 @@ export function readTranscript(text: string): ImportedEntry[] { break } } + for (const [id, tool] of tools) { + if (answered.has(id)) continue + tool.result = CUT_OFF + tool.failed = true + } return entries } /** - * Every old project in the projects folder that has a record and a transcript, but those in `known`, whose files are - * not read. A project whose files cannot be read is skipped and named on stderr, as the old app skipped it. + * Every old project in the projects folder that has a record, but those in `known`, whose files are not read. One with + * no transcript yet comes with an empty chat. A project whose files cannot be read is skipped and named on stderr, as + * the old app skipped it. */ export function legacyProjects(projectsDir: string, known: ReadonlySet): LegacyProject[] { // No projects folder, or something else in its place, holds no old chats. if (!existsSync(projectsDir) || !statSync(projectsDir).isDirectory()) return [] - return readdirSync(projectsDir, { withFileTypes: true }).flatMap((dir) => { + let dirs: Dirent[] + try { + dirs = readdirSync(projectsDir, { withFileTypes: true }) + } catch (error) { + // The app still starts, as macOS can keep the folder from it until the person allows it. The import runs at each + // start, so it comes once the folder can be listed. + console.error(`could not list ${projectsDir} for chats from before the store: ${error instanceof Error ? error.message : String(error)}`) + return [] + } + return dirs.flatMap((dir) => { const meta = join(projectsDir, dir.name, '.m3d') - if (known.has(dir.name) || !dir.isDirectory() || !existsSync(join(meta, 'project.json')) || !existsSync(join(meta, 'transcript.jsonl'))) return [] + if (known.has(dir.name) || !dir.isDirectory() || !existsSync(join(meta, 'project.json'))) return [] + const transcript = join(meta, 'transcript.jsonl') try { const record = JSON.parse(readFileSync(join(meta, 'project.json'), 'utf8')) as { title?: unknown; createdMs?: unknown } return [ @@ -94,7 +116,7 @@ export function legacyProjects(projectsDir: string, known: ReadonlySet): id: dir.name, title: str(record.title) || dir.name, createdMs: typeof record.createdMs === 'number' ? record.createdMs : 0, - entries: readTranscript(readFileSync(join(meta, 'transcript.jsonl'), 'utf8')), + entries: existsSync(transcript) ? readTranscript(readFileSync(transcript, 'utf8')) : [], }, ] } catch (error) { diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index 186be586..f28f9c9a 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -59,6 +59,41 @@ describe('the import of chats from before the store', () => { expect(await app.stderr()).toContain('2026-11-06-bad-record') }) + test('keeps a project that has no chat yet, and shows a step a quit cut off as stopped', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) + const projects = join(dir, 'projects') + cpSync(CLIP, join(projects, '2026-11-06-no-chat'), { recursive: true }) + rmSync(join(projects, '2026-11-06-no-chat', '.m3d', 'transcript.jsonl')) + cpSync(CLIP, join(projects, '2026-11-06-cut-off'), { recursive: true }) + const cut = [ + { type: 'm3d_user', text: 'A hook', images: [] }, + { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { description: 'Build the hook' } }] } }, + ] + writeFileSync(join(projects, '2026-11-06-cut-off', '.m3d', 'transcript.jsonl'), cut.map((line) => JSON.stringify(line)).join('\n')) + const app = spawnSidecar(join(dir, 'state'), projects) + const state = (await app.call('state.get')) as { projects: { id: string }[] } + expect(state.projects.map((p) => p.id).sort()).toEqual(['2026-11-06-cut-off', '2026-11-06-no-chat']) + expect(((await app.call('chat.open', { project: '2026-11-06-no-chat' })) as ChatView).entries).toEqual([]) + const steps = ((await app.call('chat.open', { project: '2026-11-06-cut-off' })) as ChatView).entries.map(({ id: _, ...entry }) => entry) + expect(steps[1]).toEqual({ kind: 'imported', role: 'tool', name: 'Bash', text: 'Build the hook', result: 'It stopped before it finished.', failed: true }) + await app.quit() + }) + + test('a projects folder that cannot be listed holds the start up for no one', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) + const projects = join(dir, 'projects') + cpSync(CLIP, join(projects, '2026-11-06-a-wall-clip'), { recursive: true }) + chmodSync(projects, 0o000) + try { + const app = spawnSidecar(join(dir, 'state'), projects) + expect(((await app.call('state.get')) as { projects: unknown[] }).projects).toEqual([]) + await app.quit() + expect(await app.stderr()).toContain('could not list') + } finally { + chmodSync(projects, 0o755) + } + }) + test('reads no old transcript on a launch after the import', async () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) const projects = join(dir, 'projects') From 212945716b9ecef363f2cc808ff2a457c19973e3 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 12:16:19 -0600 Subject: [PATCH 19/59] fix(studio): a stopped step shows as stopped, and an older chat change never puts back a working turn From CodeRabbit's review, each with a test that failed first. A step a stop or a crash left without a result showed as running for good. Once the turn no longer runs, it shows as stopped. A chat change read before chat.open's answer could arrive after it and show the turn as working again. Each chat view now carries the order its state was read in, and the window keeps the newest. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 7 +++++-- sidecar/src/protocol.ts | 6 +++++- src/studio/StudioApp.test.tsx | 20 +++++++++++++++++--- src/studio/StudioApp.tsx | 9 +++++++-- src/studio/transcript.test.ts | 9 +++++++++ src/studio/transcript.ts | 12 ++++++++++-- 6 files changed, 53 insertions(+), 10 deletions(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index c98aa703..75709695 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -88,6 +88,8 @@ export class Chats { readonly #watches = new Map>() /** The newest entry each watched chat has sent the window. */ readonly #sent = new Map() + /** Counts reads of a chat's state, in the order they were read, for `ChatView.seq`. */ + #seq = 0 /** Tools waiting for a sign-in, by provider. */ readonly #signIns = new Map void>>() @@ -130,7 +132,7 @@ export class Chats { const entries = await this.#entries(conversation) this.#sent.set(project, Math.max(this.#sent.get(project) ?? 0, entries.at(-1)?.id ?? 0)) const { running, waiting, turn } = await this.#state(conversation) - return { project, entries, running, waiting, turn } + return { project, entries, running, waiting, turn, seq: ++this.#seq } } async #watch(project: string, conversation: Conversation): Promise { @@ -151,9 +153,10 @@ export class Chats { if (await this.#read(conversation, state.failure, state.turn)) this.#resume(project, state.failure.taskId) state = await this.#state(conversation) } + const seq = ++this.#seq const entries = await this.#entries(conversation, this.#sent.get(project) ?? 0) if (entries.length > 0) this.#sent.set(project, entries.at(-1)?.id ?? 0) - this.#o.emit({ type: 'chat', project, entries, running: state.running, waiting: state.waiting, turn: state.turn }) + this.#o.emit({ type: 'chat', project, entries, running: state.running, waiting: state.waiting, turn: state.turn, seq }) } /** diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index 31c1b902..fec18377 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -78,7 +78,11 @@ export type Turn = { } /** A chat as the window shows it: committed entries, and whether a turn runs, waits, or neither. */ -export type ChatView = { project: string; entries: ChatEntry[]; running: boolean; waiting: Waiting | null; turn: Turn | null } +/** + * A chat as read at one point. `seq` grows with each read of a chat's state, so a view read earlier, such as a change + * that arrives after `chat.open` answered, never takes the place of a newer one's `running`, `waiting`, and `turn`. + */ +export type ChatView = { project: string; entries: ChatEntry[]; running: boolean; waiting: Waiting | null; turn: Turn | null; seq: number } export type AppState = { setup: Setup; settings: Settings; projects: Project[]; drafts: Record } diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 106da1b0..22070466 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -29,7 +29,7 @@ Element.prototype.scrollIntoView = vi.fn() const projectA = { id: '2026-11-06-a', conversation: 2, title: 'Project A', createdMs: 2 } const projectB = { id: '2026-11-06-b', conversation: 3, title: 'Project B', createdMs: 1 } -const view = (project: string, more: object = {}) => ({ project, entries: [], running: false, waiting: null, turn: null, ...more }) +const view = (project: string, more: object = {}) => ({ project, entries: [], running: false, waiting: null, turn: null, seq: 1, ...more }) const settings = { model: { provider: 'anthropic', modelId: 'claude-opus-5-5' }, thinking: 'high', printer: {} } afterEach(() => { @@ -182,14 +182,28 @@ describe('StudioApp', () => { fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') const reply = { id: 2, kind: 'assistant', text: 'The clip is ready.', calls: [] } - await emit({ type: 'chat', ...view(projectB.id, { entries: [reply] }) }) + await emit({ type: 'chat', ...view(projectB.id, { entries: [reply], seq: 3 }) }) const asked = { id: 1, kind: 'user', text: 'A clip', images: [] } - await act(async () => finishOpen(view(projectB.id, { entries: [asked], running: true }))) + await act(async () => finishOpen(view(projectB.id, { entries: [asked], running: true, seq: 2 }))) expect(screen.getByText('The clip is ready.')).toBeTruthy() expect(screen.getByText('A clip')).toBeTruthy() expect(screen.queryByTestId('working')).toBeNull() }) + it('never shows a turn as working again when a change read before the open chat arrives after it', async () => { + let finishOpen: (view: unknown) => void = () => {} + mockSidecar({ 'chat.open': () => new Promise((resolve) => (finishOpen = resolve)) }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + const asked = { id: 1, kind: 'user', text: 'A clip', images: [] } + await emit({ type: 'chat', ...view(projectB.id, { entries: [asked], running: true, seq: 2 }) }) + const reply = { id: 2, kind: 'assistant', text: 'The clip is ready.', calls: [] } + await act(async () => finishOpen(view(projectB.id, { entries: [asked, reply], running: false, seq: 3 }))) + expect(screen.getByText('The clip is ready.')).toBeTruthy() + expect(screen.queryByTestId('working')).toBeNull() + }) + it('saves a draft at once when the person moves to another project', async () => { mockSidecar({}, [projectA, projectB]) render() diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index e643a32d..d8e2a92c 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -26,11 +26,16 @@ const START = 'new' const DRAFT_PAUSE_MS = 400 /** A chat change on top of a view: entries joined by id, and the turn's state as the change gives it. */ +/** + * A chat with a change applied. Entries only grow, so each one is kept. The running state comes from whichever was read + * last, so a change read before the open chat, which can arrive after it, never puts back a turn that already ended. + */ function merge(view: View, change: View): View { const byId = new Map(view.entries.map((entry) => [entry.id, entry])) for (const entry of change.entries) byId.set(entry.id, entry) const entries = [...byId.values()].sort((a, b) => a.id - b.id) - return { ...view, entries, running: change.running, waiting: change.waiting, turn: change.turn } + if (change.seq < view.seq) return { ...view, entries } + return { ...view, entries, running: change.running, waiting: change.waiting, turn: change.turn, seq: change.seq } } function newestAutoOpen(files: ProjectFile[]): ProjectFile | undefined { @@ -82,7 +87,7 @@ export default function StudioApp() { const early = useRef([]) composerText.current = text - const items = useMemo(() => buildChat(view?.entries ?? []), [view?.entries]) + const items = useMemo(() => buildChat(view?.entries ?? [], view?.running ?? true), [view?.entries, view?.running]) const running = view?.running ?? false useUpdateEvents() diff --git a/src/studio/transcript.test.ts b/src/studio/transcript.test.ts index 35cf6783..c0a2ff5e 100644 --- a/src/studio/transcript.test.ts +++ b/src/studio/transcript.test.ts @@ -52,4 +52,13 @@ describe('buildChat', () => { { kind: 'tool', id: 'c1', label: 'Ran', summary: 'uv run python build/model.py', detail: 'uv run python build/model.py' }, ]) }) + + it('shows a step left without a result as stopped once its turn no longer runs', () => { + const entries = [ + { id: 1, kind: 'user' as const, text: 'Thicker', images: [] }, + { id: 2, kind: 'assistant' as const, text: '', calls: [{ id: 'c1', name: 'run', args: { args: ['run', 'python', 'build/model.py'] } }] }, + ] + expect(buildChat(entries, false)[1]).toMatchObject({ kind: 'tool', id: 'c1', result: 'It stopped before it finished.', failed: true }) + expect(buildChat(entries, true)[1]).not.toHaveProperty('result') + }) }) diff --git a/src/studio/transcript.ts b/src/studio/transcript.ts index 7dac4b01..8b5592dc 100644 --- a/src/studio/transcript.ts +++ b/src/studio/transcript.ts @@ -46,9 +46,10 @@ function written(name: string, args: Record): string | undefine /** * Committed chat entries as chat items: one line per tool step with its result attached, and after each turn a card - * of the files it wrote that open in the preview, print files first. + * of the files it wrote that open in the preview, print files first. Once the turn no longer `running`, a step left + * without a result, as a stop or a crash leaves it, shows as stopped. */ -export function buildChat(entries: ChatEntry[]): ChatItem[] { +export function buildChat(entries: ChatEntry[], running = true): ChatItem[] { const items: ChatItem[] = [] const tools = new Map() let files: string[] = [] @@ -93,5 +94,12 @@ export function buildChat(entries: ChatEntry[]): ChatItem[] { } } endTurn() + if (!running) { + for (const { item } of tools.values()) { + if (item.result !== undefined) continue + item.result = 'It stopped before it finished.' + item.failed = true + } + } return items } From 1418897eef8580538c9b55df44730fba03e3b692 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 12:16:19 -0600 Subject: [PATCH 20/59] fix(sidecar): a login shell's PATH comes at once, and a slice refused during an install can run later From CodeRabbit's review. A login shell whose startup files leave a program holding its output, such as an agent, kept the PATH lookup waiting for that program until the deadline, and then lost the PATH. The lookup now takes what the shell printed once the shell ends, with a test that failed first. A slice refused while an update installed was kept as its operation's result, so it failed for good. The pass is now taken before the operation. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/sidecar.rs | 48 ++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index abb82194..76fbbe62 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -270,14 +270,21 @@ fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { } let mut child = command.spawn().ok()?; let mut stdout = child.stdout.take()?; - let (sent, said) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut out = Vec::new(); - let _ = std::io::Read::read_to_end(&mut stdout, &mut out); - let _ = sent.send(out); + // What the shell printed so far. A program its startup files started, such as an agent, can keep stdout open long + // after the shell ends, so the reader is not waited on for its end. + let printed = std::sync::Arc::new(Mutex::new(Vec::new())); + let (closed, eof) = std::sync::mpsc::channel::<()>(); + std::thread::spawn({ + let printed = printed.clone(); + move || { + let mut chunk = [0u8; 4096]; + while let Ok(n @ 1..) = std::io::Read::read(&mut stdout, &mut chunk) { + printed.lock().unwrap_or_else(|e| e.into_inner()).extend_from_slice(&chunk[..n]); + } + let _ = closed.send(()); + } }); - let out = said.recv_timeout(deadline).ok(); - // Startup files can send stdout elsewhere and go on running, so the shell's end has the same deadline. + // Startup files can send stdout elsewhere and go on running, so the shell's end has the deadline too. let ended = loop { match child.try_wait() { Ok(Some(_)) => break true, @@ -285,6 +292,9 @@ fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { _ => break false, } }; + // The shell has ended, so all it printed is in the pipe: a short wait lets the reader take it. + let _ = eof.recv_timeout(std::time::Duration::from_millis(100)); + let out = ended.then(|| std::mem::take(&mut *printed.lock().unwrap_or_else(|e| e.into_inner()))); if !ended { #[cfg(unix)] if let Ok(group) = i32::try_from(child.id()) { @@ -572,9 +582,12 @@ async fn answer(app: &AppHandle, run: u64, method: &str, params: Value) -> Resul "slice" => { let SliceCall { operation, request } = parse(method, params)?; let cache = app.path().app_cache_dir().map_err(|e| format!("finding the app's cache folder: {e}"))?.join("bambu-presets"); + // The pass comes first, so a refusal while an update installs is not kept as the operation's result, and + // the same slice can run once the gate opens. let state = app.state::>().inner().clone(); - once_per_operation(&app.state::().slices, operation, || { - with_own_pass(state, || async move { + let sidecar = app.state::(); + with_own_pass(state, || { + once_per_operation(&sidecar.slices, operation, || async move { let sliced = tauri::async_runtime::spawn_blocking(move || studio::slice::slice(&request, &cache)) .await .map_err(|e| format!("the slice task failed: {e}"))??; @@ -635,6 +648,23 @@ mod tests { assert!(shell_path("/bin/sh", std::time::Duration::from_secs(5)).is_some_and(|path| !path.is_empty())); } + #[test] + fn a_login_shell_that_leaves_a_program_holding_its_output_still_gives_its_path_at_once() { + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("agent-shell"); + // Startup files that start an agent, which keeps the shell's stdout open after the shell ends. + std::fs::write(&shell, "#!/bin/sh +sleep 30 & +printf '__M3D_PATH__/opt/tools/bin' +").unwrap(); + let mut perms = std::fs::metadata(&shell).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&shell, perms).unwrap(); + let started = std::time::Instant::now(); + assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_secs(5)).as_deref(), Some("/opt/tools/bin")); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed()); + } + #[test] fn the_sidecar_reaches_only_inference_items_in_the_keychain() { assert_eq!(keychain_item("anthropic").as_deref(), Ok("inference.anthropic")); From d7f92e53a029226159a4690b2da582da5ca84ad4 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 12:16:44 -0600 Subject: [PATCH 21/59] chore(formal): the update models still describe the sidecar after CodeRabbit's fixes A slice takes its own pass before its operation's result is kept, still an Enter and Leave of the build gate, and the chat view's read order and the login PATH lookup do not touch the gate. TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index f9e6f496..e32e4bf5 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -4,7 +4,7 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ 581b0e828a547528a87785cd366c6e57252dea85823cc1402fcaf7b113a2255c src-tauri/src/printer/mod.rs dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs -4db78bc41b64823eaa86e8e5e7a5a989ae360573221a1186d28fe2f36e94cb67 src-tauri/src/sidecar.rs +de4855f8fcd176bf68d39ed896636e2801a1af0da85e79c7efb7a74ac4c62ca2 src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts -67ddb84d7beb10f4a65c83677c18cddbf890c68684072f3e11a94fe705cae814 sidecar/src/chat.ts +88d196c032776e23c62c33f2a985909c1583cf589ef7d661735bfdef209e7db0 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts From 8809d02fb0027cd2db62e705265e7ee434d8bcdd Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 12:18:57 -0600 Subject: [PATCH 22/59] fix(studio): an odd old line never drops a whole old chat, and the window keeps new projects and clears a sent error From CodeRabbit's second batch, each with a test that failed first. An old transcript line whose content was not a list of blocks, or held a null block, threw and made the import skip the whole project. Such content now has no steps to show. The saved project list that arrives at the start no longer drops a project made while it was on its way, and a send that goes through clears that project's earlier send error. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/legacy.ts | 6 +++--- sidecar/test/switch.test.ts | 4 +++- src/studio/StudioApp.test.tsx | 26 ++++++++++++++++++++++++-- src/studio/StudioApp.tsx | 4 +++- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/sidecar/src/legacy.ts b/sidecar/src/legacy.ts index 98a02235..93e076b5 100644 --- a/sidecar/src/legacy.ts +++ b/sidecar/src/legacy.ts @@ -49,13 +49,14 @@ export function readTranscript(text: string): ImportedEntry[] { const event = parsed as Record // A helper agent's inner steps stayed inside its one line. if (event.parent_tool_use_id) continue - const blocks = ((event.message as { content?: Block[] | string } | undefined)?.content ?? []) as Block[] | string + // Content in any form but a list of blocks, such as the plain text a few old lines held, has no steps to show. + const content = (event.message as { content?: unknown } | undefined)?.content + const blocks = Array.isArray(content) ? content.filter((block): block is Block => typeof block === 'object' && block !== null) : [] switch (event.type) { case 'm3d_user': entries.push({ role: 'user', text: str(event.text), images: Array.isArray(event.images) ? event.images.map(str) : [] }) break case 'assistant': - if (typeof blocks === 'string') break for (const block of blocks) { if (block.type === 'text' && block.text?.trim()) entries.push({ role: 'assistant', text: block.text }) if (block.type === 'tool_use' && block.id !== undefined && block.name !== undefined) { @@ -66,7 +67,6 @@ export function readTranscript(text: string): ImportedEntry[] { } break case 'user': - if (typeof blocks === 'string') break for (const block of blocks) { const tool = block.type === 'tool_result' && block.tool_use_id !== undefined ? tools.get(block.tool_use_id) : undefined if (tool === undefined) continue diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index f28f9c9a..8a2cc8dd 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -50,7 +50,9 @@ describe('the import of chats from before the store', () => { writeFileSync(join(projects, '2026-11-06-bad-record', '.m3d', 'project.json'), '{"title":') cpSync(CLIP, join(projects, '2026-11-06-null-line'), { recursive: true }) const transcript = join(projects, '2026-11-06-null-line', '.m3d', 'transcript.jsonl') - writeFileSync(transcript, `null\n${readFileSync(transcript, 'utf8')}`) + // Lines a cut-off or hand-edited transcript can hold: no event, content that is not a list, and a null block. + const odd = ['null', '{"type":"assistant","message":{"content":{"text":"x"}}}', '{"type":"assistant","message":{"content":[null]}}'] + writeFileSync(transcript, `${odd.join('\n')}\n${readFileSync(transcript, 'utf8')}`) const app = spawnSidecar(join(dir, 'state'), projects) const state = (await app.call('state.get')) as { projects: { id: string }[] } expect(state.projects.map((p) => p.id)).toEqual(['2026-11-06-null-line']) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 22070466..f7d830ff 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' type Listener = (event: { payload: unknown }) => void const { invokeMock, listeners } = vi.hoisted(() => ({ invokeMock: vi.fn(), listeners: { sidecar: [] as Listener[], drop: [] as Listener[] } })) @@ -111,7 +111,11 @@ describe('StudioApp', () => { it("keeps a slow send's failure with the project it was sent to, and sending again reuses its request id", async () => { let failSend: (reason: string) => void = () => {} - mockSidecar({ 'chat.send': () => new Promise((_, reject) => (failSend = reject)) }, [projectA, projectB]) + let sends = 0 + mockSidecar( + { 'chat.send': () => (++sends === 1 ? new Promise((_, reject) => (failSend = reject)) : Promise.resolve(null)) }, + [projectA, projectB], + ) render() fireEvent.click(await screen.findByText('Project A')) await screen.findByRole('banner') @@ -131,6 +135,24 @@ describe('StudioApp', () => { await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) const [first, second] = sidecarCalls('chat.send') expect(second?.requestId).toBe(first?.requestId) + // The send went through this time, so its failure is no longer shown. + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()) + }) + + it('keeps a project made while the saved state was still on its way', async () => { + let finishState: (state: unknown) => void = () => {} + mockSidecar({ + 'state.get': () => new Promise((resolve) => (finishState = resolve)), + 'projects.create': () => Promise.resolve({ id: '2026-11-06-a-clip', conversation: 4, title: 'A clip for my desk', createdMs: 3 }), + }) + render() + fireEvent.change(await screen.findByTestId('prompt'), { target: { value: 'A clip for my desk' } }) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: {} })) + const sidebar = within(screen.getByRole('navigation', { name: 'Projects' })) + expect(sidebar.getByText('A clip for my desk')).toBeTruthy() + expect(sidebar.getByText('Project B')).toBeTruthy() }) it('shows what a waiting turn needs, and a proxy sign-in goes to the sidecar', async () => { diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index d8e2a92c..112403f8 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -100,7 +100,8 @@ export default function StudioApp() { .then((state) => { // Drafts saved in this window since are newer, and text already typed stays. drafts.current = { ...state.drafts, ...drafts.current } - setProjects(state.projects) + // A project made while this was on its way is newer than the saved list, so it stays. + setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) if (currentId.current === null && !typedOnStart.current) setText(drafts.current[START]?.text ?? '') }) .catch((err: unknown) => setError(String(err))) @@ -309,6 +310,7 @@ export default function StudioApp() { pending.current.set(id, sendable) await sidecar('chat.send', { project: id, requestId: sendable.id, text: message, attachments: sendable.images }) pending.current.delete(id) + setSendErrors(({ [id]: _, ...rest }) => rest) if (drafts.current[id]?.text.trim() === message) drafts.current = { ...drafts.current, [id]: { text: '' } } // Text typed while this was sending is a new message, so only the sent text is cleared, // and only in the view that sent it. From 5bd71cd0be38b473b2f86288494cf7df4910ff37 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 13:39:23 -0600 Subject: [PATCH 23/59] fix(sidecar): a chat's read order keeps growing when the sidecar starts again The read order chat views carry (2129457) started from 0 in each sidecar run. After a crash the window still held the views the crashed run sent, so it took every view of the new run as older and kept showing the resumed turn as working after it finished. The live crash test on aojdevmac-m5 showed it. The order now starts from the clock in microseconds and always grows, and the crash test checks the new run's views come after. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 16 +++++++++++++--- sidecar/src/protocol.ts | 5 +++-- sidecar/test/agent.test.ts | 4 +++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 75709695..8e1356ce 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -88,7 +88,7 @@ export class Chats { readonly #watches = new Map>() /** The newest entry each watched chat has sent the window. */ readonly #sent = new Map() - /** Counts reads of a chat's state, in the order they were read, for `ChatView.seq`. */ + /** The last `ChatView.seq` given. */ #seq = 0 /** Tools waiting for a sign-in, by provider. */ readonly #signIns = new Map void>>() @@ -132,7 +132,7 @@ export class Chats { const entries = await this.#entries(conversation) this.#sent.set(project, Math.max(this.#sent.get(project) ?? 0, entries.at(-1)?.id ?? 0)) const { running, waiting, turn } = await this.#state(conversation) - return { project, entries, running, waiting, turn, seq: ++this.#seq } + return { project, entries, running, waiting, turn, seq: this.#nextSeq() } } async #watch(project: string, conversation: Conversation): Promise { @@ -144,6 +144,16 @@ export class Chats { }) } + /** + * The order of a read of a chat's state. It grows across sidecar runs too: a sidecar that starts again after a crash + * reads after the one before it, so the window, which still holds that one's views, takes the new run's views. It + * starts from the clock in microseconds, and each read is after the last. + */ + #nextSeq(): number { + this.#seq = Math.max(this.#seq + 1, Date.now() * 1000) + return this.#seq + } + async #changed(project: string, conversation: Conversation): Promise { let state = await this.#state(conversation) const recorded = (await this.#o.store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null @@ -153,7 +163,7 @@ export class Chats { if (await this.#read(conversation, state.failure, state.turn)) this.#resume(project, state.failure.taskId) state = await this.#state(conversation) } - const seq = ++this.#seq + const seq = this.#nextSeq() const entries = await this.#entries(conversation, this.#sent.get(project) ?? 0) if (entries.length > 0) this.#sent.set(project, entries.at(-1)?.id ?? 0) this.#o.emit({ type: 'chat', project, entries, running: state.running, waiting: state.waiting, turn: state.turn, seq }) diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index fec18377..3e687328 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -79,8 +79,9 @@ export type Turn = { /** A chat as the window shows it: committed entries, and whether a turn runs, waits, or neither. */ /** - * A chat as read at one point. `seq` grows with each read of a chat's state, so a view read earlier, such as a change - * that arrives after `chat.open` answered, never takes the place of a newer one's `running`, `waiting`, and `turn`. + * A chat as read at one point. `seq` grows with each read of a chat's state, across sidecar runs too, so a view read + * earlier, such as a change that arrives after `chat.open` answered, never takes the place of a newer one's `running`, + * `waiting`, and `turn`. */ export type ChatView = { project: string; entries: ChatEntry[]; running: boolean; waiting: Waiting | null; turn: Turn | null; seq: number } diff --git a/sidecar/test/agent.test.ts b/sidecar/test/agent.test.ts index 603f760d..0a600b31 100644 --- a/sidecar/test/agent.test.ts +++ b/sidecar/test/agent.test.ts @@ -107,6 +107,7 @@ describe('a crash mid-turn', () => { const log = join(where.projects, project, 'ops.log') await waitForFile(log) while (!readFileSync(log, 'utf8').includes('op2')) await Bun.sleep(25) + const before = ((await first.call('chat.open', { project })) as ChatView).seq first.child.kill('SIGKILL') await first.child.exited // The watch on op2 ends it within a second of the crash, before `go` would let it finish. @@ -114,7 +115,8 @@ describe('a crash mid-turn', () => { const reopened = start(where, 'ops') await reopened.next() - await reopened.call('chat.open', { project }) + // The window still holds the views the crashed sidecar sent, so the new one's must come after them. + expect(((await reopened.call('chat.open', { project })) as ChatView).seq).toBeGreaterThan(before) writeFileSync(join(where.projects, project, 'go'), '') await answered(reopened, project, 'done') From 6bd0ff490b753c72cba92ae114c1cebf7773c327 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 13:39:41 -0600 Subject: [PATCH 24/59] chore(formal): the update models still describe the sidecar after the read-order fix The chat view's read order does not touch the update gate. TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index e32e4bf5..d632542c 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs de4855f8fcd176bf68d39ed896636e2801a1af0da85e79c7efb7a74ac4c62ca2 src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts -88d196c032776e23c62c33f2a985909c1583cf589ef7d661735bfdef209e7db0 sidecar/src/chat.ts +cfb05441bd060931bc33531f2110b964b139c7fb9c81a59061d1175598dee645 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts From 38140ad29199f6b17748cead6b3fbe4c588d2d7e Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 13:43:16 -0600 Subject: [PATCH 25/59] test: the unreadable-folder test skips under root, and the new login shell test runs on Unix only From CodeRabbit. Root lists a folder whatever its mode, so the test cannot make one unreadable there. The login shell test uses Unix permissions, as the test beside it does, which is already Unix only. Co-Authored-By: Claude Opus 5.5 --- sidecar/test/switch.test.ts | 3 ++- src-tauri/src/sidecar.rs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index 8a2cc8dd..b2a6b312 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -81,7 +81,8 @@ describe('the import of chats from before the store', () => { await app.quit() }) - test('a projects folder that cannot be listed holds the start up for no one', async () => { + // Root can list a folder whatever its mode, so there the folder cannot be made unreadable. + test.skipIf(process.getuid?.() === 0)('a projects folder that cannot be listed holds the start up for no one', async () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-import-')) const projects = join(dir, 'projects') cpSync(CLIP, join(projects, '2026-11-06-a-wall-clip'), { recursive: true }) diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 76fbbe62..725885e2 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -648,6 +648,7 @@ mod tests { assert!(shell_path("/bin/sh", std::time::Duration::from_secs(5)).is_some_and(|path| !path.is_empty())); } + #[cfg(unix)] #[test] fn a_login_shell_that_leaves_a_program_holding_its_output_still_gives_its_path_at_once() { let dir = tempfile::tempdir().unwrap(); From baf85d14fa39a76950dfc3b675246d657d9c6689 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 13:45:13 -0600 Subject: [PATCH 26/59] chore(formal): record sidecar.rs's hash after its test became Unix only Only a test's attribute changed, so the models still describe the code. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index d632542c..02f1bf08 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -4,7 +4,7 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ 581b0e828a547528a87785cd366c6e57252dea85823cc1402fcaf7b113a2255c src-tauri/src/printer/mod.rs dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs -de4855f8fcd176bf68d39ed896636e2801a1af0da85e79c7efb7a74ac4c62ca2 src-tauri/src/sidecar.rs +1650fd15c96b0250add6023bc2f198f3dd6eeef9ea021de4fa8a2d3d64e39739 src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts cfb05441bd060931bc33531f2110b964b139c7fb9c81a59061d1175598dee645 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts From cdfbaa8441d6ff73e9ec7463f88f6a95033819d5 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 13:49:51 -0600 Subject: [PATCH 27/59] fix(sidecar): a chat change takes its read order as soon as it reads the state From CodeRabbit. A chat change read the state, then waited on the waiting document before it took its order, so a chat.open that read in between could get the earlier order with the newer state. Each read now takes its order before anything else is awaited. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 8e1356ce..7c8e5755 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -155,15 +155,18 @@ export class Chats { } async #changed(project: string, conversation: Conversation): Promise { + // Each read of the state takes its order at once, before anything else is awaited, so a chat.open that reads + // between them is ordered by when it read too. let state = await this.#state(conversation) + let seq = this.#nextSeq() const recorded = (await this.#o.store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { // The waiting document is not part of the view a watch follows, so its commit wakes no one: read it here. // A login saved since the turn went out lets it go on now: the sign-in that saved it found no wait to let go. if (await this.#read(conversation, state.failure, state.turn)) this.#resume(project, state.failure.taskId) state = await this.#state(conversation) + seq = this.#nextSeq() } - const seq = this.#nextSeq() const entries = await this.#entries(conversation, this.#sent.get(project) ?? 0) if (entries.length > 0) this.#sent.set(project, entries.at(-1)?.id ?? 0) this.#o.emit({ type: 'chat', project, entries, running: state.running, waiting: state.waiting, turn: state.turn, seq }) From fcbeec0eff6635bd6d996fb8495ead23b9bddc67 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 13:50:10 -0600 Subject: [PATCH 28/59] chore(formal): record chat.ts's hash after the read-order change The read order does not touch the update gate. TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 02f1bf08..1c885ca4 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs 1650fd15c96b0250add6023bc2f198f3dd6eeef9ea021de4fa8a2d3d64e39739 src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts -cfb05441bd060931bc33531f2110b964b139c7fb9c81a59061d1175598dee645 sidecar/src/chat.ts +5b323f4792ed14bd485fb961709b8d315b732cc7333c7af9363d93e982b39ee2 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts From 23e36e6a0f23039d28bcdfb3fcd61aee8973f737 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 13:56:07 -0600 Subject: [PATCH 29/59] fix(sidecar): the login PATH lookup stops reading once it is done, and ignores what comes after the PATH From CodeRabbit. The reader of the login shell's output lived on after the lookup, so a program the startup files left writing, such as one that logs, kept filling it without bound. Its output after the PATH also ran into the PATH. The shell now prints the PATH between two markers, and the reader stops and closes the pipe once the lookup is done. A test with `yes` left running failed first on both counts. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/sidecar.rs | 47 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 725885e2..979225ef 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -251,7 +251,7 @@ fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { let end = std::time::Instant::now() + deadline; let mut command = std::process::Command::new(shell); command - .args(["-ilc", &format!("printf '{marker}%s' \"$PATH\"")]) + .args(["-ilc", &format!("printf '{marker}%s{marker}' \"$PATH\"")]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()); @@ -271,14 +271,20 @@ fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { let mut child = command.spawn().ok()?; let mut stdout = child.stdout.take()?; // What the shell printed so far. A program its startup files started, such as an agent, can keep stdout open long - // after the shell ends, so the reader is not waited on for its end. + // after the shell ends, so the reader is not waited on for its end. Once the lookup is done the reader stops at its + // next read and closes the pipe, so a program that keeps writing is never read again. let printed = std::sync::Arc::new(Mutex::new(Vec::new())); + let done = std::sync::Arc::new(AtomicBool::new(false)); let (closed, eof) = std::sync::mpsc::channel::<()>(); std::thread::spawn({ let printed = printed.clone(); + let done = done.clone(); move || { let mut chunk = [0u8; 4096]; while let Ok(n @ 1..) = std::io::Read::read(&mut stdout, &mut chunk) { + if done.load(Ordering::Relaxed) { + break; + } printed.lock().unwrap_or_else(|e| e.into_inner()).extend_from_slice(&chunk[..n]); } let _ = closed.send(()); @@ -295,6 +301,7 @@ fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { // The shell has ended, so all it printed is in the pipe: a short wait lets the reader take it. let _ = eof.recv_timeout(std::time::Duration::from_millis(100)); let out = ended.then(|| std::mem::take(&mut *printed.lock().unwrap_or_else(|e| e.into_inner()))); + done.store(true, Ordering::Relaxed); if !ended { #[cfg(unix)] if let Ok(group) = i32::try_from(child.id()) { @@ -306,7 +313,13 @@ fn shell_path(shell: &str, deadline: std::time::Duration) -> Option { let _ = child.wait(); } let out = out?; - String::from_utf8_lossy(&out).rsplit_once(marker).map(|(_, path)| path.trim().to_owned()).filter(|path| !path.is_empty()) + // The PATH is what the shell printed between the two markers. A program its startup files left running can write + // after it, so nothing past the second marker counts. + String::from_utf8_lossy(&out) + .split_once(marker) + .and_then(|(_, rest)| rest.split_once(marker)) + .map(|(path, _)| path.trim().to_owned()) + .filter(|path| !path.is_empty()) } /// The only variables the sidecar gets. Its tools need a PATH that finds `uv` and a HOME for @@ -656,7 +669,7 @@ mod tests { // Startup files that start an agent, which keeps the shell's stdout open after the shell ends. std::fs::write(&shell, "#!/bin/sh sleep 30 & -printf '__M3D_PATH__/opt/tools/bin' +printf '__M3D_PATH__/opt/tools/bin__M3D_PATH__' ").unwrap(); let mut perms = std::fs::metadata(&shell).unwrap().permissions(); std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); @@ -666,6 +679,32 @@ printf '__M3D_PATH__/opt/tools/bin' assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed()); } + #[cfg(unix)] + #[test] + fn a_program_that_keeps_writing_to_the_login_shells_output_is_not_read_after_the_lookup() { + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("noisy-shell"); + let pid = dir.path().join("pid"); + std::fs::write(&shell, format!("#!/bin/sh\nyes &\necho $! > {}\nprintf '__M3D_PATH__/opt/tools/bin__M3D_PATH__'\n", pid.display())).unwrap(); + let mut perms = std::fs::metadata(&shell).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&shell, perms).unwrap(); + assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_secs(5)).as_deref(), Some("/opt/tools/bin")); + let pid = std::fs::read_to_string(&pid).unwrap().trim().parse::().unwrap(); + // Once nothing reads the pipe, the next write ends the writer. + let gone = (0..50).any(|_| { + std::thread::sleep(std::time::Duration::from_millis(100)); + // SAFETY: kill with signal 0 only asks whether the process still exists. + let alive = unsafe { libc::kill(pid, 0) } == 0; + !alive + }); + if !gone { + // SAFETY: ends the test's own writer, which outlived the lookup. + unsafe { libc::kill(pid, libc::SIGKILL) }; + } + assert!(gone, "the lookup still read the output of a program its shell left running"); + } + #[test] fn the_sidecar_reaches_only_inference_items_in_the_keychain() { assert_eq!(keychain_item("anthropic").as_deref(), Ok("inference.anthropic")); From 5024e483275a1b0b0d53fbe9b8a6466fcb6d80e6 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 13:56:27 -0600 Subject: [PATCH 30/59] chore(formal): record sidecar.rs's hash after the login PATH change The login PATH lookup does not touch the update gate. TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 1c885ca4..60f8b465 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -4,7 +4,7 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ 581b0e828a547528a87785cd366c6e57252dea85823cc1402fcaf7b113a2255c src-tauri/src/printer/mod.rs dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs -1650fd15c96b0250add6023bc2f198f3dd6eeef9ea021de4fa8a2d3d64e39739 src-tauri/src/sidecar.rs +adc22bee7f98a0717baf0999cccaf528142ad4e08e4ed88d17bedc45ec97d511 src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts 5b323f4792ed14bd485fb961709b8d315b732cc7333c7af9363d93e982b39ee2 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts From b6e65f465ff77e38487d525fb2d7d77a92be577d Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 14:33:26 -0600 Subject: [PATCH 31/59] fix(studio): the open chat is read again after the engine restarts, a matching later edit stays, and old stopped steps stay stopped From Pullfrog's first full review, each with a test that failed first. - An engine that started again sent no chat state for an idle chat, so a turn whose last entry the crash cut off from the window kept showing Working. The window reads the open chat again on ready, and the sidecar sends each chat's state once it has reconciled, as the window may read before that. - Text typed during a send that read the same as the sent text was cleared, while its queued save kept it. A send now clears the composer and the draft only when nothing was typed since it went out. - A step a stop left without a result showed as running again once the next turn started. A later message now marks it stopped too. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 7 +++++++ sidecar/test/agent.test.ts | 23 ++++++++++++++++++++++- src/studio/StudioApp.test.tsx | 32 ++++++++++++++++++++++++++++++++ src/studio/StudioApp.tsx | 25 ++++++++++++++++++++----- src/studio/transcript.test.ts | 3 +++ src/studio/transcript.ts | 23 ++++++++++++++--------- 6 files changed, 98 insertions(+), 15 deletions(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 7c8e5755..0fbf02ec 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -434,6 +434,13 @@ export class Chats { if (state.turn !== null && state.turn.app !== version) await this.#outdated(conversation.id) else if (record.failure !== null && (await this.#savedSince(record, waiting.provider))) this.#resume(project.id, record.failure) } + // The window may have read a chat before this, and what was set right here, such as a wait, reaches no watch. So + // each chat's state as reconciled goes to the window, an idle chat's too. + for (const project of projects) { + const conversation = await this.#conversation(project.id) + const { running, waiting, turn } = await this.#state(conversation) + this.#o.emit({ type: 'chat', project: project.id, entries: [], running, waiting, turn, seq: this.#nextSeq() }) + } } /** diff --git a/sidecar/test/agent.test.ts b/sidecar/test/agent.test.ts index 0a600b31..56177567 100644 --- a/sidecar/test/agent.test.ts +++ b/sidecar/test/agent.test.ts @@ -97,6 +97,24 @@ describe('a send', () => { }) }) +describe('a restart', () => { + test("sends each chat's state once it has reconciled, even a chat that is idle", async () => { + const where = scratch() + const first = start(where, 'models') + await first.next() + const project = await newProject(first) + await send(first, project, 'r1', 'hello') + await first.until((event) => event.type === 'chat' && event.project === project && event.running === false && (event.entries as ChatEntry[]).some((e) => e.kind === 'assistant'), 20_000) + first.child.kill('SIGKILL') + await first.child.exited + const reopened = start(where, 'models') + await reopened.next() + const state = await reopened.until((event) => event.type === 'chat' && event.project === project, 5_000) + expect(state.running).toBe(false) + await reopened.quit() + }, 30_000) +}) + describe('a crash mid-turn', () => { test('the reopened sidecar finishes the turn and never repeats a finished tool call', async () => { const where = scratch() @@ -115,7 +133,10 @@ describe('a crash mid-turn', () => { const reopened = start(where, 'ops') await reopened.next() - // The window still holds the views the crashed sidecar sent, so the new one's must come after them. + // The window still holds the views the crashed sidecar sent, so the new one's must come after them, and the + // reopened sidecar sends each chat's state once it has reconciled, as the window may have read it before that. + const reconciled = await reopened.until((event) => event.type === 'chat' && event.project === project) + expect(reconciled.seq).toBeGreaterThan(before) expect(((await reopened.call('chat.open', { project })) as ChatView).seq).toBeGreaterThan(before) writeFileSync(join(where.projects, project, 'go'), '') await answered(reopened, project, 'done') diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index f7d830ff..89d5344a 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -212,6 +212,38 @@ describe('StudioApp', () => { expect(screen.queryByTestId('working')).toBeNull() }) + it('reads the open chat again when the engine starts again, so a turn that ended before the crash shows as ended', async () => { + let opens = 0 + mockSidecar({ + 'chat.open': (params) => { + opens += 1 + return Promise.resolve(view(String(params.project), opens === 1 ? { running: true, seq: 1 } : { running: false, seq: 5 })) + }, + }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByTestId('working') + await emit({ type: 'engine', state: 'restarting' }) + await emit({ type: 'ready', version: '1.2.3' }) + await waitFor(() => expect(screen.queryByTestId('working')).toBeNull()) + expect(sidecarCalls('chat.open')).toHaveLength(2) + }) + + it('keeps text typed during a send as the next message, even when it reads the same as the sent one', async () => { + let finishSend: (value: unknown) => void = () => {} + mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)) }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + type('Make it blue') + type('Make it red') + await act(async () => finishSend(null)) + expect(prompt().value).toBe('Make it red') + }) + it('never shows a turn as working again when a change read before the open chat arrives after it', async () => { let finishOpen: (view: unknown) => void = () => {} mockSidecar({ 'chat.open': () => new Promise((resolve) => (finishOpen = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 112403f8..a995678b 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -84,6 +84,8 @@ export default function StudioApp() { const composerText = useRef('') /** The project whose chat view is loaded. Chat changes for the open project wait in `early` until it is. */ const loaded = useRef(null) + /** Counts the person's edits to the composer, so a send clears only the text it sent, not a later edit that reads the same. */ + const edits = useRef(0) const early = useRef([]) composerText.current = text @@ -110,10 +112,19 @@ export default function StudioApp() { let engineChanged = false const unlisten = onSidecarEvent((event) => { switch (event.type) { - case 'ready': + case 'ready': { engineChanged = true setEngine(`Engine ${event.version}`) + // An engine that started again holds what a crash cut off from the window, such as a turn's last entry, so + // the open chat is read again. The changes that arrive meanwhile are kept, as when it first opened. + const project = currentId.current + if (project !== null) { + loaded.current = null + early.current = [] + void load(project, loadRequest.current).catch((err: unknown) => setError(String(err))) + } break + } case 'engine': engineChanged = true setEngine(event.state === 'stopped' ? event.detail : 'Engine restarting') @@ -160,6 +171,7 @@ export default function StudioApp() { } const type = (value: string) => { + edits.current += 1 setText(value) if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) const timer = window.setTimeout(() => void saveDraft().catch((err: unknown) => setError(String(err))), DRAFT_PAUSE_MS) @@ -263,6 +275,7 @@ export default function StudioApp() { let sentTo: string | null = null try { await saveDraft() + const sentEdits = edits.current let project = current if (!project) { const created = await sidecar('projects.create', { title: message || 'New part' }) @@ -311,10 +324,12 @@ export default function StudioApp() { await sidecar('chat.send', { project: id, requestId: sendable.id, text: message, attachments: sendable.images }) pending.current.delete(id) setSendErrors(({ [id]: _, ...rest }) => rest) - if (drafts.current[id]?.text.trim() === message) drafts.current = { ...drafts.current, [id]: { text: '' } } - // Text typed while this was sending is a new message, so only the sent text is cleared, - // and only in the view that sent it. - if (loadRequest.current === request) setText((now) => (now.trim() === message ? '' : now)) + // Text typed while this was sending is a new message, even one that reads the same, so the sent text is cleared + // only when nothing was typed since, and only in the view that sent it. + if (edits.current === sentEdits) { + if (drafts.current[id]?.text.trim() === message) drafts.current = { ...drafts.current, [id]: { text: '' } } + if (loadRequest.current === request) setText((now) => (now.trim() === message ? '' : now)) + } // A picture added while these imported waits for the next message. setAttachments((prev) => prev.filter((a) => !outgoing.includes(a))) outgoing.forEach(releaseAttachment) diff --git a/src/studio/transcript.test.ts b/src/studio/transcript.test.ts index c0a2ff5e..20fc0a66 100644 --- a/src/studio/transcript.test.ts +++ b/src/studio/transcript.test.ts @@ -60,5 +60,8 @@ describe('buildChat', () => { ] expect(buildChat(entries, false)[1]).toMatchObject({ kind: 'tool', id: 'c1', result: 'It stopped before it finished.', failed: true }) expect(buildChat(entries, true)[1]).not.toHaveProperty('result') + // A later message starts a new turn, which cannot run the earlier step again. + const later = [...entries, { id: 3, kind: 'user' as const, text: 'Thinner', images: [] }] + expect(buildChat(later, true)[1]).toMatchObject({ kind: 'tool', id: 'c1', result: 'It stopped before it finished.', failed: true }) }) }) diff --git a/src/studio/transcript.ts b/src/studio/transcript.ts index 8b5592dc..0997a489 100644 --- a/src/studio/transcript.ts +++ b/src/studio/transcript.ts @@ -46,13 +46,22 @@ function written(name: string, args: Record): string | undefine /** * Committed chat entries as chat items: one line per tool step with its result attached, and after each turn a card - * of the files it wrote that open in the preview, print files first. Once the turn no longer `running`, a step left - * without a result, as a stop or a crash leaves it, shows as stopped. + * of the files it wrote that open in the preview, print files first. A step left without a result, as a stop or a + * crash leaves it, shows as stopped once a later message starts a new turn, or once its own turn no longer `running`. */ export function buildChat(entries: ChatEntry[], running = true): ChatItem[] { const items: ChatItem[] = [] const tools = new Map() let files: string[] = [] + // A step with no result that a later message or the end of its turn leaves behind was stopped, as a stop or a crash + // leaves it. It cannot run again. + const stopUnfinished = () => { + for (const { item } of tools.values()) { + if (item.result !== undefined) continue + item.result = 'It stopped before it finished.' + item.failed = true + } + } const endTurn = () => { const shown = [...new Set(files)].filter(autoOpens).sort((a, b) => Number(is3mf(b)) - Number(is3mf(a))) if (shown.length > 0) items.push({ kind: 'files', files: shown }) @@ -62,6 +71,7 @@ export function buildChat(entries: ChatEntry[], running = true): ChatItem[] { switch (entry.kind) { case 'user': endTurn() + stopUnfinished() items.push({ kind: 'user', text: entry.text, images: entry.images }) break case 'assistant': @@ -84,6 +94,7 @@ export function buildChat(entries: ChatEntry[], running = true): ChatItem[] { case 'imported': if (entry.role === 'user') { endTurn() + stopUnfinished() items.push({ kind: 'user', text: entry.text, images: entry.images }) } else if (entry.role === 'assistant') { items.push({ kind: 'text', text: entry.text }) @@ -94,12 +105,6 @@ export function buildChat(entries: ChatEntry[], running = true): ChatItem[] { } } endTurn() - if (!running) { - for (const { item } of tools.values()) { - if (item.result !== undefined) continue - item.result = 'It stopped before it finished.' - item.failed = true - } - } + if (!running) stopUnfinished() return items } From 5e390f392a145abc42b6f475250fc458d0358532 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 14:33:45 -0600 Subject: [PATCH 32/59] chore(formal): record chat.ts's hash after the reconciled chat state Sending each chat's state after reconcile does not touch the update gate. TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 60f8b465..1e22310a 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs adc22bee7f98a0717baf0999cccaf528142ad4e08e4ed88d17bedc45ec97d511 src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts -5b323f4792ed14bd485fb961709b8d315b732cc7333c7af9363d93e982b39ee2 sidecar/src/chat.ts +860fcae9bcc1f04f6a3de1352f13b2923c011bc8dbb91f52de5d8cc493172f30 sidecar/src/chat.ts 97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts From 4cb51ec5cf6f1467c7ac501a3d1ee788ae5ffe53 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 14:52:26 -0600 Subject: [PATCH 33/59] fix(studio): Send waits for a ready agent, drafts keep their pictures and survive a quit, and a blocked Documents folder says how to let it in From Astra's acceptance review of 5024e48, with window and sidecar tests written first. - Setup: the sidecar's setup.check names what the agent lacks before a send: the chosen model's sign-in, uv, or a Python uv can run, each with its fix. Send stays off until the check finds nothing, the notice offers the sign-in, and a sign-in checks again. A login refused or expired during a turn still waits in the turn with its fix (C). - Drafts: a draft keeps its pictures as file paths. A pasted picture is kept in a file of the sidecar's first. A normal quit or the window's close asks the window to save the draft still waiting, and waits for it briefly. A restart for an update cannot be held. - Old chats: a projects folder macOS keeps from the app is named in a banner that opens the Privacy setting, and Try again runs the import again without a restart. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 41 ++++++- sidecar/src/app.ts | 24 +++- sidecar/src/legacy.ts | 23 ++-- sidecar/src/methods.ts | 43 ++++++- sidecar/src/protocol.ts | 21 +++- sidecar/src/store.ts | 6 +- sidecar/test/switch.test.ts | 24 +++- sidecar/test/tools.test.ts | 33 ++++++ src-tauri/build.rs | 2 + src-tauri/capabilities/default.json | 2 + src-tauri/src/lib.rs | 27 ++++- src-tauri/src/studio/mod.rs | 1 + src-tauri/src/studio/quit.rs | 94 +++++++++++++++ src/studio/Chat.tsx | 34 +++--- src/studio/StudioApp.test.tsx | 90 ++++++++++++++- src/studio/StudioApp.tsx | 173 +++++++++++++++++++--------- src/studio/Waiting.tsx | 58 +++++++++- src/studio/api.ts | 7 ++ 18 files changed, 592 insertions(+), 111 deletions(-) create mode 100644 src-tauri/src/studio/quit.rs diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index e60f6492..3a5d2a71 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -24,6 +24,7 @@ import { NodeExecutionEnv } from '@earendil-works/pi-durable/env/node' import { createEditTool, createReadTool, createWriteTool } from '@earendil-works/pi-durable/tools' import { arr, int, obj, opt, str } from '@earendil-works/pi-schema' import type { Host } from './host' +import type { Need } from './protocol' import { isLoginFailure } from './inference' import { textOf } from './transcript' import workflow from './workflow.md' with { type: 'text' } @@ -41,6 +42,40 @@ const OUTPUT_TAIL = 20_000 /** The only variables a command the agent runs gets. */ const COMMAND_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR'] as const +/** The environment a command the agent runs gets: the sidecar's own values of `COMMAND_ENV`, and nothing else. */ +function commandEnv(): Record { + const vars: Record = {} + for (const name of COMMAND_ENV) { + const value = process.env[name] + if (value !== undefined) vars[name] = value + } + return vars +} + +/** Whether `command` runs to a zero exit in the environment the agent's commands get. A missing program does not. */ +function succeeds(command: string[]): boolean { + try { + return Bun.spawnSync(command, { env: commandEnv(), stdout: 'ignore', stderr: 'ignore' }).exitCode === 0 + } catch { + return false + } +} + +/** + * What the `run` tool lacks in this sidecar's environment: uv, which runs every build and check script, and a Python + * that uv can run them with. A Python uv would have to download first counts as missing, as a turn could not count on + * the network for it. + */ +export function toolchainNeeds(): Need[] { + if (!succeeds(['uv', '--version'])) { + return [{ kind: 'uv', fix: 'Install uv, the tool the agent runs its build scripts with (docs.astral.sh/uv), then check again.' }] + } + if (!succeeds(['uv', 'python', 'find'])) { + return [{ kind: 'python', fix: 'Install a Python for uv: run "uv python install" in Terminal, then check again.' }] + } + return [] +} + /** * Runs a command under a watch on the sidecar. Pi starts each command in a process group of its own, so it can end the * whole tree on a timeout or a stop, but a sidecar killed outright ends nothing. The watch ends the group, command and @@ -228,11 +263,7 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) execute: async ({ args, timeoutSeconds }, api, context) => { const op = operation<{ exitCode: number; output: string }>(api, context) const { env } = op - const vars: Record = { M3D_OPERATION_ID: op.id } - for (const name of COMMAND_ENV) { - const value = process.env[name] - if (value !== undefined) vars[name] = value - } + const vars: Record = { ...commandEnv(), M3D_OPERATION_ID: op.id } const log = `${op.receipt}.log` const exited = `${op.receipt}.exit` // A command a crash cut off may still be ending: wait until its watch has ended it or it left its exit code. diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index b2e2c1ea..8d612a14 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -1,3 +1,4 @@ +import { dirname, join } from 'node:path' import { parseArgs } from 'node:util' import type { MutableModels } from '@earendil-works/pi-ai' import { createRegistry } from '@earendil-works/pi-durable' @@ -88,13 +89,26 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis console.error(`agent tools: ${tools.map((tool) => tool.name).join(', ')}`) // The import asks Rust for nothing, so it ends before the pipe opens and the first call sees every old chat. Projects - // already in the index were imported before, and their transcripts are not read again. - const known = new Set((await store.projects()).map((project) => project.id)) - const imported = await store.importLegacy(legacyProjects(projects, known)) - if (imported.length > 0) console.error(`imported ${imported.length} chats from before the store: ${imported.join(', ')}`) + // already in the index were imported before, and their transcripts are not read again. It runs again when the person + // asks, as after letting the app read a projects folder macOS kept from it. + const importOld = async () => { + const known = new Set((await store.projects()).map((project) => project.id)) + const { found, blocked } = legacyProjects(projects, known) + const imported = await store.importLegacy(found) + if (imported.length > 0) console.error(`imported ${imported.length} chats from before the store: ${imported.join(', ')}`) + return { imported: imported.length, blocked } + } + let oldChats = await importOld() + const legacy = { + blocked: () => oldChats.blocked, + retry: async () => { + oldChats = await importOld() + return oldChats + }, + } // The pipe opens before the pass is asked for and before the reconcile, which asks Rust's Keychain whether a // waited-on login is there now. - const serving = host.serve(handlers({ store, logins, chats, version }), process.stdin) + const serving = host.serve(handlers({ store, logins, chats, version, legacy, pictures: join(dirname(data), 'pictures') }), process.stdin) // Work a crash cut off resumes only with the pass. Without it an update is installing, and the work resumes after // the restart. const resumable = await busy.watch(store.harness) diff --git a/sidecar/src/legacy.ts b/sidecar/src/legacy.ts index 93e076b5..ea160901 100644 --- a/sidecar/src/legacy.ts +++ b/sidecar/src/legacy.ts @@ -91,21 +91,25 @@ export function readTranscript(text: string): ImportedEntry[] { /** * Every old project in the projects folder that has a record, but those in `known`, whose files are not read. One with * no transcript yet comes with an empty chat. A project whose files cannot be read is skipped and named on stderr, as - * the old app skipped it. + * the old app skipped it. `blocked` names the folder when it could not be read at all, as when macOS keeps Documents + * from the app until the person allows it. The app still starts, and the import runs again at each start and when the + * person asks, so it comes once the folder can be read. */ -export function legacyProjects(projectsDir: string, known: ReadonlySet): LegacyProject[] { - // No projects folder, or something else in its place, holds no old chats. - if (!existsSync(projectsDir) || !statSync(projectsDir).isDirectory()) return [] +export function legacyProjects(projectsDir: string, known: ReadonlySet): { found: LegacyProject[]; blocked: string | null } { + const blocked = (error: unknown) => { + console.error(`could not list ${projectsDir} for chats from before the store: ${error instanceof Error ? error.message : String(error)}`) + return { found: [], blocked: projectsDir } + } let dirs: Dirent[] try { + // No projects folder, or something else in its place, holds no old chats. + if (!statSync(projectsDir).isDirectory()) return { found: [], blocked: null } dirs = readdirSync(projectsDir, { withFileTypes: true }) } catch (error) { - // The app still starts, as macOS can keep the folder from it until the person allows it. The import runs at each - // start, so it comes once the folder can be listed. - console.error(`could not list ${projectsDir} for chats from before the store: ${error instanceof Error ? error.message : String(error)}`) - return [] + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return { found: [], blocked: null } + return blocked(error) } - return dirs.flatMap((dir) => { + const found = dirs.flatMap((dir) => { const meta = join(projectsDir, dir.name, '.m3d') if (known.has(dir.name) || !dir.isDirectory() || !existsSync(join(meta, 'project.json'))) return [] const transcript = join(meta, 'transcript.jsonl') @@ -124,4 +128,5 @@ export function legacyProjects(projectsDir: string, known: ReadonlySet): return [] } }) + return { found, blocked: null } } diff --git a/sidecar/src/methods.ts b/sidecar/src/methods.ts index 6e1beb59..9f75f3f8 100644 --- a/sidecar/src/methods.ts +++ b/sidecar/src/methods.ts @@ -1,7 +1,11 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { basename, join } from 'node:path' import { arr, bool, compile, lit, obj, opt, str, union } from '@earendil-works/pi-schema' +import { toolchainNeeds } from './agent' import type { Chats } from './chat' import type { Handlers } from './host' import type { Logins } from './inference' +import type { AppState, Need } from './protocol' import type { Store } from './store' export interface Services { @@ -9,6 +13,10 @@ export interface Services { logins: Logins chats: Chats version: string + /** The import of the chats from before the store: the folder it could not read, and a way to run it again. */ + legacy: { blocked(): string | null; retry(): Promise<{ imported: number; blocked: string | null }> } + /** The folder pasted pictures are kept in until they are sent, outside every project. */ + pictures: string } const step = union([lit('welcome'), lit('check'), lit('model'), lit('printer'), lit('ready')]) @@ -18,22 +26,49 @@ const printer = obj({ machine: str(), process: str(), filament: str() }) const provider = union([lit('anthropic'), lit('openai-codex'), lit('opencode-go')]) /** Every method the window can call, each parsed at the pipe before it runs. */ -export function handlers({ store, logins, chats, version }: Services): Handlers { +export function handlers({ store, logins, chats, version, legacy, pictures }: Services): Handlers { + const state = async (): Promise => ({ ...(await store.state()), importBlocked: legacy.blocked() }) return { 'app.ping': { params: compile(obj({})), run: async () => ({ version }) }, - 'state.get': { params: compile(obj({})), run: () => store.state() }, + 'state.get': { params: compile(obj({})), run: state }, 'setup.set': { params: compile(obj({ step, done: bool() })), run: async (setup) => { await store.setSetup(setup) - return store.state() + return state() }, }, 'settings.set': { params: compile(obj({ model: opt(model), thinking: opt(thinking), printer: opt(printer) })), run: async (change) => { await store.setSettings(change) - return store.state() + return state() + }, + }, + 'setup.check': { + params: compile(obj({})), + run: async () => { + // The chosen model's provider must have a login. A refused or expired one still waits in the turn, with its fix. + const { settings } = await store.state() + const login = (await logins.status()).find((l) => l.provider === settings.model.provider) + const needs: Need[] = + login !== undefined && login.login === null + ? [{ kind: 'login', provider: login.provider, name: login.name, fix: `Sign in to ${login.name} before you send.` }] + : [] + return [...needs, ...toolchainNeeds()] + }, + }, + 'legacy.import': { params: compile(obj({})), run: () => legacy.retry() }, + 'drafts.picture': { + params: compile(obj({ name: str({ minLength: 1 }), data: str() })), + run: async ({ name, data }) => { + // Only a plain file name: the picture's folder is the sidecar's, not one the window names. + if (basename(name) !== name || name.startsWith('.')) throw new Error(`${name} is not a picture's file name`) + const folder = join(pictures, crypto.randomUUID()) + mkdirSync(folder, { recursive: true }) + const path = join(folder, name) + writeFileSync(path, Buffer.from(data, 'base64')) + return path }, }, 'drafts.set': { diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index 3e687328..43883290 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -85,7 +85,20 @@ export type Turn = { */ export type ChatView = { project: string; entries: ChatEntry[]; running: boolean; waiting: Waiting | null; turn: Turn | null; seq: number } -export type AppState = { setup: Setup; settings: Settings; projects: Project[]; drafts: Record } +/** What the store keeps of the app. */ +export type StoredState = { setup: Setup; settings: Settings; projects: Project[]; drafts: Record } + +/** + * The app as the window sees it. `importBlocked` names the projects folder when the chats from before the store could + * not be read from it, as when macOS keeps Documents from the app, so the window can say how to let them in. + */ +export type AppState = StoredState & { importBlocked: string | null } + +/** Something the agent needs before a message can go out, and the one thing that gives it. */ +export type Need = + | { kind: 'login'; provider: ProviderId; name: string; fix: string } + | { kind: 'uv'; fix: string } + | { kind: 'python'; fix: string } /** What the window can ask the sidecar, by method name. */ export interface Methods { @@ -94,6 +107,12 @@ export interface Methods { 'setup.set': { params: Setup; result: AppState } 'settings.set': { params: Partial; result: AppState } 'drafts.set': { params: { key: string } & Draft; result: null } + /** Keeps a pasted picture in a file of its own, outside any project, and answers its path, for a draft to keep. */ + 'drafts.picture': { params: { name: string; data: string }; result: string } + /** What the agent needs before a send: the chosen model's sign-in, uv, and a Python uv can run. Empty when ready. */ + 'setup.check': { params: Record; result: Need[] } + /** Imports the chats from before the store again, as after the person let the app read the projects folder. */ + 'legacy.import': { params: Record; result: { imported: number; blocked: string | null } } 'projects.create': { params: { title: string }; result: Project } 'auth.status': { params: Record; result: Login[] } /** A key, or for Anthropic a proxy's base URL and token. */ diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index dcab45cf..98e97528 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -13,7 +13,7 @@ import { type Conversation, defineDoc, type HarnessOptions, Harness, type Regist import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node' import type { LoginIndex } from './inference' import type { LegacyProject } from './legacy' -import type { AppState, Draft, LoginKind, Project, Settings, Setup, Turn, Waiting } from './protocol' +import type { Draft, LoginKind, Project, Settings, Setup, StoredState, Turn, Waiting } from './protocol' const context = BACKGROUND_CONTEXT @@ -180,9 +180,9 @@ export class Store implements LoginIndex { } /** The app's state at one point in the store's history: the four documents are read in one commit. */ - async state(): Promise { + async state(): Promise { const id = this.root.id - const state: AppState = await this.harness.commit( + const state: StoredState = await this.harness.commit( async (tx) => // A plain copy, since the documents a commit hands out are only valid inside it. JSON.parse( diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index b2a6b312..af1b3148 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -40,6 +40,20 @@ describe('chats from before the store', () => { }) }) +describe('a picture pasted into a draft', () => { + test('is kept in a file of its own, outside any project, so the draft can keep it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await app.next() + const path = (await app.call('drafts.picture', { name: 'pasted.png', data: Buffer.from('png bytes').toString('base64') })) as string + expect(path.startsWith(dir)).toBe(true) + expect(path.endsWith('pasted.png')).toBe(true) + expect(readFileSync(path, 'utf8')).toBe('png bytes') + await expect(app.call('drafts.picture', { name: '../escape.png', data: '' })).rejects.toThrow() + await app.quit() + }) +}) + describe('the import of chats from before the store', () => { const CLIP = join(LEGACY, '2026-11-06-a-wall-clip') @@ -89,7 +103,15 @@ describe('the import of chats from before the store', () => { chmodSync(projects, 0o000) try { const app = spawnSidecar(join(dir, 'state'), projects) - expect(((await app.call('state.get')) as { projects: unknown[] }).projects).toEqual([]) + const state = (await app.call('state.get')) as { projects: unknown[]; importBlocked: string | null } + expect(state.projects).toEqual([]) + expect(state.importBlocked).toContain(projects) + // The person allows the folder, and the import runs again at once. + chmodSync(projects, 0o755) + expect(await app.call('legacy.import', {})).toEqual({ imported: 1, blocked: null }) + const after = (await app.call('state.get')) as { projects: { id: string }[]; importBlocked: string | null } + expect(after.projects.map((p) => p.id)).toEqual(['2026-11-06-a-wall-clip']) + expect(after.importBlocked).toBeNull() await app.quit() expect(await app.stderr()).toContain('could not list') } finally { diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index 68d060e5..e8e661fa 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -125,6 +125,39 @@ describe('the run tool', () => { }, 30_000) }) +describe('the check before a send', () => { + /** A uv on PATH that knows its version, and finds a Python only when `python` is true. */ + function toolchain(python: boolean) { + const dir = mkdtempSync(join(tmpdir(), 'm3d-uv-')) + writeFileSync(join(dir, 'uv'), `#!/bin/sh\n[ "$1" = --version ] && { echo "uv 0.9.0"; exit 0; }\n[ "$1 $2" = "python find" ] && { ${python ? 'echo /usr/bin/python3; exit 0' : 'exit 2'}; }\nexit 1\n`) + chmodSync(join(dir, 'uv'), 0o755) + return `${dir}:/usr/bin:/bin` + } + const check = async (path: string, key: boolean) => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-check-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { env: { PATH: path } }) + await app.next() + if (key) await app.call('auth.setKey', { provider: 'anthropic', key: 'token' }) + const needs = (await app.call('setup.check', {})) as { kind: string; fix: string }[] + await app.quit() + return needs + } + + test('names a missing sign-in and a missing uv, each with its fix', async () => { + const needs = await check('/usr/bin:/bin', false) + expect(needs.map((need) => need.kind)).toEqual(['login', 'uv']) + expect(needs.every((need) => need.fix.length > 0)).toBe(true) + }) + + test('names a missing Python once uv is there', async () => { + expect((await check(toolchain(false), true)).map((need) => need.kind)).toEqual(['python']) + }) + + test('has nothing to name when the sign-in, uv, and Python are all there', async () => { + expect(await check(toolchain(true), true)).toEqual([]) + }) +}) + describe("a command's log", () => { test('is followed from where the last read stopped, and a long burst or a recovery gets only its end', () => { const log = join(mkdtempSync(join(tmpdir(), 'm3d-log-')), 'op.json.log') diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 35482e03..6c851090 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -99,5 +99,7 @@ const APP_COMMANDS: &[&str] = &[ "studio_save_bytes", "studio_reveal", "studio_open_in_bambu", + "studio_flushed", + "studio_open_files_privacy", "sidecar", ]; diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e2dbff83..db1ab750 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -92,6 +92,8 @@ "allow-studio-save-bytes", "allow-studio-reveal", "allow-studio-open-in-bambu", + "allow-studio-flushed", + "allow-studio-open-files-privacy", "allow-sidecar" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c5bc0cd5..ee6674da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -75,6 +75,7 @@ pub fn run() { .manage(agent::commands::AgentTurns::default()) .manage(updater::PendingUpdate::default()) .manage(sidecar::Sidecar::default()) + .manage(studio::quit::QuitFlush::default()) .invoke_handler(tauri::generate_handler![ get_app_state, set_active_view, @@ -157,6 +158,8 @@ pub fn run() { studio::files::studio_save_bytes, studio::files::studio_reveal, studio::files::studio_open_in_bambu, + studio::quit::studio_flushed, + studio::quit::studio_open_files_privacy, sidecar::sidecar, ]) .setup(|app| { @@ -337,11 +340,27 @@ pub fn run() { }) .build(app_context()) .expect("error while building tauri application") - .run(|app, event| { - // The sidecar finishes its commits and exits before the app does. - if let tauri::RunEvent::Exit = event { - app.state::().shutdown(); + .run(|app, event| match event { + // A normal quit, or the main window's close, first lets the window save the text it has not saved yet. A + // restart for an update cannot be held, so it goes on as before. + tauri::RunEvent::ExitRequested { api, code, .. } if code != Some(tauri::RESTART_EXIT_CODE) => { + if studio::quit::QuitFlush::hold(app, move |app| app.exit(code.unwrap_or(0))) { + api.prevent_exit(); + } + } + tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { api, .. }, .. } if label == "main" => { + let closing = label.clone(); + if studio::quit::QuitFlush::hold(app, move |app| { + if let Some(window) = app.get_webview_window(&closing) { + let _ = window.destroy(); + } + }) { + api.prevent_close(); + } } + // The sidecar finishes its commits and exits before the app does. + tauri::RunEvent::Exit => app.state::().shutdown(), + _ => {} }); } diff --git a/src-tauri/src/studio/mod.rs b/src-tauri/src/studio/mod.rs index f8f8ed83..c5bbb913 100644 --- a/src-tauri/src/studio/mod.rs +++ b/src-tauri/src/studio/mod.rs @@ -3,6 +3,7 @@ //! chats themselves live in the sidecar's store (`crate::sidecar`). pub mod files; +pub mod quit; pub mod slice; use std::path::PathBuf; diff --git a/src-tauri/src/studio/quit.rs b/src-tauri/src/studio/quit.rs new file mode 100644 index 00000000..23c889dd --- /dev/null +++ b/src-tauri/src/studio/quit.rs @@ -0,0 +1,94 @@ +//! A normal quit, or closing the window, waits briefly for the window to save what the person typed but has not +//! saved yet. The window saves its draft a moment after typing pauses, so text typed just before a quit would be lost +//! without this. A restart for an update cannot be held: Tauri ignores a hold on it, and the update waits for idle. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use tauri::{AppHandle, Emitter, Manager, Runtime, State}; +use tokio::sync::Notify; + +/// The event that asks the window to save its draft now. +const FLUSH: &str = "studio://flush"; + +/// How long a quit waits for the window. A window that does not answer, as one that is gone, never holds it longer. +const FLUSH_WAIT: Duration = Duration::from_millis(1500); + +#[derive(Default)] +pub struct QuitFlush { + /// Set once the window was asked, so the quit or close that follows the answer goes through. + asked: AtomicBool, + saved: Notify, +} + +impl QuitFlush { + /// Asks the window to save its draft, the first time only, and then runs `then`. Returns whether it asked, so the + /// caller holds the quit or close until `then` runs. + pub fn hold(app: &AppHandle, then: impl FnOnce(AppHandle) + Send + 'static) -> bool { + let flush = app.state::(); + if flush.asked.swap(true, Ordering::SeqCst) { + return false; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if app.emit(FLUSH, ()).is_ok() { + let _ = tokio::time::timeout(FLUSH_WAIT, app.state::().saved.notified()).await; + } + then(app); + }); + true + } +} + +/// The window's answer: its draft is saved. +#[tauri::command] +pub fn studio_flushed(flush: State<'_, QuitFlush>) { + flush.saved.notify_one(); +} + +/// Opens the Files and Folders pane of macOS Privacy settings, where the person lets the app read Documents. +#[tauri::command] +pub fn studio_open_files_privacy() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("/usr/bin/open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders") + .status() + .map_err(|e| format!("opening Privacy settings: {e}")) + .and_then(|status| if status.success() { Ok(()) } else { Err(format!("opening Privacy settings ended with {status}")) }) + } + #[cfg(not(target_os = "macos"))] + { + Err("Privacy settings for folders are a macOS setting.".to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_quit_asks_the_window_once_and_goes_on_without_an_answer() { + let app = tauri::test::mock_app(); + app.manage(QuitFlush::default()); + let (done, finished) = std::sync::mpsc::channel(); + let first = done.clone(); + assert!(QuitFlush::hold(app.handle(), move |_| first.send(()).unwrap()), "the first quit waits for the window"); + assert!(!QuitFlush::hold(app.handle(), move |_| done.send(()).unwrap()), "the quit that follows goes through"); + // No window answers in the mock, so the wait ends at its limit. + finished.recv_timeout(FLUSH_WAIT + Duration::from_secs(2)).expect("the quit goes on"); + } + + #[test] + fn the_windows_answer_ends_the_wait_at_once() { + let app = tauri::test::mock_app(); + app.manage(QuitFlush::default()); + let (done, finished) = std::sync::mpsc::channel(); + let started = std::time::Instant::now(); + assert!(QuitFlush::hold(app.handle(), move |_| done.send(()).unwrap())); + std::thread::sleep(Duration::from_millis(50)); + studio_flushed(app.state::()); + finished.recv_timeout(FLUSH_WAIT).expect("the quit goes on"); + assert!(started.elapsed() < FLUSH_WAIT, "the answer ended the wait, after {:?}", started.elapsed()); + } +} diff --git a/src/studio/Chat.tsx b/src/studio/Chat.tsx index a8e29d2a..47161f33 100644 --- a/src/studio/Chat.tsx +++ b/src/studio/Chat.tsx @@ -5,13 +5,18 @@ import { FileActions } from './RightPanel' import type { ChatItem } from './transcript' /** A picture waiting to be sent: a dropped file on disk, or pasted bytes. */ -export type Attachment = - | { kind: 'path'; name: string; path: string } - | { kind: 'bytes'; name: string; bytes: Uint8Array; previewUrl: string } +/** + * A picture waiting to go with the next message: a file on disk, so a draft can keep it. A pasted picture is first kept + * in a file of the sidecar's, and shows its preview while the window has it. + */ +export type Attachment = { name: string; path: string; previewUrl?: string } -/** Frees a pasted picture's preview once it is sent or removed. */ +/** The picture a draft keeps at `path`. */ +export const savedAttachment = (path: string): Attachment => ({ name: path.split(/[\\/]/).pop() ?? path, path }) + +/** Frees a pasted picture's preview once it is sent, removed, or left. */ export function releaseAttachment(attachment: Attachment): void { - if (attachment.kind === 'bytes') URL.revokeObjectURL(attachment.previewUrl) + if (attachment.previewUrl !== undefined) URL.revokeObjectURL(attachment.previewUrl) } function inline(text: string): ReactNode[] { @@ -104,6 +109,8 @@ interface ComposerProps { onText: (text: string) => void attachments: Attachment[] onAttach: (files: Attachment[]) => void + /** Keeps a pasted picture in a file, and answers its path. */ + onPaste: (name: string, bytes: Uint8Array) => Promise onRemove: (index: number) => void /** Sends the text and pictures. The owner clears them only once the send succeeds. */ onSend: () => void @@ -115,7 +122,7 @@ interface ComposerProps { autoFocus?: boolean } -export function Composer({ text, onText, attachments, onAttach, onRemove, onSend, onStop, busy, running, placeholder, autoFocus }: ComposerProps) { +export function Composer({ text, onText, attachments, onAttach, onPaste, onRemove, onSend, onStop, busy, running, placeholder, autoFocus }: ComposerProps) { const empty = !text.trim() && attachments.length === 0 const paste = async (event: React.ClipboardEvent) => { @@ -124,19 +131,18 @@ export function Composer({ text, onText, attachments, onAttach, onRemove, onSend event.preventDefault() onAttach( await Promise.all( - pictures.map(async (file, i) => ({ - kind: 'bytes' as const, - name: file.name && file.name !== 'image.png' ? file.name : `pasted-${Date.now()}-${i}.${file.type.split('/')[1] ?? 'png'}`, - bytes: new Uint8Array(await file.arrayBuffer()), - previewUrl: URL.createObjectURL(file), - })), + pictures.map(async (file, i) => { + const name = file.name && file.name !== 'image.png' ? file.name : `pasted-${Date.now()}-${i}.${file.type.split('/')[1] ?? 'png'}` + const path = await onPaste(name, new Uint8Array(await file.arrayBuffer())) + return { name, path, previewUrl: URL.createObjectURL(file) } + }), ), ) } const pick = async () => { const path = await pickFile({ filters: [{ name: 'Pictures', extensions: ['png', 'jpg', 'jpeg', 'heic', 'webp', 'gif'] }] }) - if (path) onAttach([{ kind: 'path', name: path.split(/[\\/]/).pop() ?? path, path }]) + if (path) onAttach([savedAttachment(path)]) } return ( @@ -145,7 +151,7 @@ export function Composer({ text, onText, attachments, onAttach, onRemove, onSend
    {attachments.map((a, i) => (
  • - {a.kind === 'bytes' ? {a.name} : {a.name}} + {a.previewUrl !== undefined ? {a.name} : {a.name}}
  • ))} diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 89d5344a..e0aacae8 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -3,12 +3,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' type Listener = (event: { payload: unknown }) => void -const { invokeMock, listeners } = vi.hoisted(() => ({ invokeMock: vi.fn(), listeners: { sidecar: [] as Listener[], drop: [] as Listener[] } })) +const { invokeMock, listeners } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + listeners: { sidecar: [] as Listener[], drop: [] as Listener[], flush: [] as Listener[] }, +})) vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })) vi.mock('@tauri-apps/api/event', () => ({ - listen: (_name: string, listener: Listener) => { - listeners.sidecar.push(listener) + listen: (name: string, listener: Listener) => { + ;(name === 'studio://flush' ? listeners.flush : listeners.sidecar).push(listener) return Promise.resolve(() => {}) }, })) @@ -37,6 +40,7 @@ afterEach(() => { invokeMock.mockReset() listeners.sidecar.length = 0 listeners.drop.length = 0 + listeners.flush.length = 0 }) /** An event from the sidecar, as Rust forwards it to the window. */ @@ -48,7 +52,7 @@ const type = (text: string) => fireEvent.change(prompt(), { target: { value: tex type Calls = Record) => Promise> /** The sidecar as the window sees it: one `sidecar` command, by method. */ -function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Record = {}) { +function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Record = {}) { invokeMock.mockImplementation((command: string, args: { method: string; params: Record }) => { if (command === 'update_check') return Promise.resolve({ status: 'upToDate' }) if (command === 'studio_files') return Promise.resolve([]) @@ -60,9 +64,11 @@ function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Recor case 'app.ping': return Promise.resolve({ version: '1.2.3' }) case 'state.get': - return Promise.resolve({ setup: { step: 'ready', done: true }, settings, projects, drafts }) + return Promise.resolve({ setup: { step: 'ready', done: true }, settings, projects, drafts, importBlocked: null }) case 'chat.open': return Promise.resolve(view(String(args.params.project))) + case 'setup.check': + return Promise.resolve([]) default: return Promise.resolve(null) } @@ -197,6 +203,80 @@ describe('StudioApp', () => { expect(first?.attachments).toEqual(['refs/photo.jpg']) }) + it("keeps a draft's pictures, and brings them back when the project opens again", async () => { + mockSidecar() + const first = render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + await waitFor(() => { + const saves = sidecarCalls('drafts.set') + expect(saves[saves.length - 1]).toEqual({ key: projectB.id, text: '', attachments: ['/Users/me/photo.jpg'] }) + }) + first.unmount() + + mockSidecar({}, [projectB], { [projectB.id]: { text: 'like this', attachments: ['/Users/me/photo.jpg'] } }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + expect(prompt().value).toBe('like this') + expect(screen.getByTestId('attachments').textContent).toContain('photo.jpg') + }) + + it('saves the text still waiting to be saved when the app quits, then says it is done', async () => { + mockSidecar() + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('half a thought') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await waitFor(() => expect(invokeMock.mock.calls.some(([command]) => command === 'studio_flushed')).toBe(true)) + expect(sidecarCalls('drafts.set')).toEqual([{ key: projectB.id, text: 'half a thought', attachments: [] }]) + }) + + it('keeps Send off until the agent can run, and names the fix', async () => { + let needs: unknown[] = [{ kind: 'uv', fix: 'Install uv, then check again.' }] + mockSidecar({ 'setup.check': () => Promise.resolve(needs) }) + render() + fireEvent.click(await screen.findByText('Project B')) + expect((await screen.findByTestId('readiness')).textContent).toContain('Install uv, then check again.') + type('A clip') + expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(true) + needs = [] + fireEvent.click(screen.getByText('Check again')) + await waitFor(() => expect(screen.queryByTestId('readiness')).toBeNull()) + expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(false) + }) + + it('asks for the sign-in a provider needs before a send, and checks again once it is done', async () => { + let needs: unknown[] = [{ kind: 'login', provider: 'anthropic', name: 'Anthropic', fix: 'Sign in to Anthropic before you send.' }] + mockSidecar({ 'setup.check': () => Promise.resolve(needs) }) + render() + fireEvent.click(await screen.findByText('Project B')) + const notice = await screen.findByTestId('readiness') + expect(notice.textContent).toContain('Sign in to Anthropic before you send.') + expect(screen.getByLabelText('API key or token')).toBeTruthy() + needs = [] + await emit({ type: 'login', provider: 'anthropic', step: 'done' }) + await waitFor(() => expect(screen.queryByTestId('readiness')).toBeNull()) + }) + + it('says when macOS keeps the old chats from the app, and imports them once the person allows it', async () => { + let blocked: string | null = '/Users/me/Documents/Materialize 3D' + mockSidecar({ + 'state.get': () => Promise.resolve({ setup: { step: 'ready', done: true }, settings, projects: blocked === null ? [projectA, projectB] : [projectB], drafts: {}, importBlocked: blocked }), + 'legacy.import': () => { + blocked = null + return Promise.resolve({ imported: 1, blocked: null }) + }, + }) + render() + expect((await screen.findByTestId('import-blocked')).textContent).toContain('Documents') + fireEvent.click(screen.getByText('Try again')) + await waitFor(() => expect(screen.queryByTestId('import-blocked')).toBeNull()) + expect(await screen.findByText('Project A')).toBeTruthy() + }) + it('keeps chat changes that arrive while the chat opens', async () => { let finishOpen: (view: unknown) => void = () => {} mockSidecar({ 'chat.open': () => new Promise((resolve) => (finishOpen = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index a995678b..26b82798 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -1,12 +1,12 @@ import { getCurrentWebview } from '@tauri-apps/api/webview' import { openUrl } from '@tauri-apps/plugin-opener' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { LoginEvent, ChatView as View, Project } from '../../sidecar/src/protocol' -import { autoOpens, onSidecarEvent, sidecar, studio, type ProjectFile } from './api' -import { ChatView, Composer, releaseAttachment, type Attachment } from './Chat' +import type { AppState, Draft, LoginEvent, Need, ChatView as View, Project } from '../../sidecar/src/protocol' +import { autoOpens, onQuitFlush, onSidecarEvent, sidecar, studio, type ProjectFile } from './api' +import { ChatView, Composer, releaseAttachment, savedAttachment, type Attachment } from './Chat' import RightPanel, { type Tab } from './RightPanel' import { buildChat } from './transcript' -import { SignInAsk, WaitingBanner } from './Waiting' +import { ImportBlockedBanner, ReadinessBanner, SignInAsk, WaitingBanner } from './Waiting' import { UpdateBanner } from '../components/UpdateBanner' import { checkForUpdatesAtLaunch, useUpdateEvents } from '../stores/updates' import './studio.css' @@ -25,7 +25,6 @@ const START = 'new' /** How long typing pauses before the draft is saved. */ const DRAFT_PAUSE_MS = 400 -/** A chat change on top of a view: entries joined by id, and the turn's state as the change gives it. */ /** * A chat with a change applied. Entries only grow, so each one is kept. The running state comes from whichever was read * last, so a change read before the open chat, which can arrive after it, never puts back a turn that already ended. @@ -38,6 +37,13 @@ function merge(view: View, change: View): View { return { ...view, entries, running: change.running, waiting: change.waiting, turn: change.turn, seq: change.seq } } +/** A pasted picture's bytes as base64, to cross the sidecar's pipe. */ +function toBase64(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)) + return btoa(binary) +} + function newestAutoOpen(files: ProjectFile[]): ProjectFile | undefined { return files.filter((f) => autoOpens(f.rel)).sort((a, b) => b.modifiedMs - a.modifiedMs)[0] } @@ -59,6 +65,10 @@ export default function StudioApp() { const [engine, setEngine] = useState('Engine starting') /** What a sign-in needs from the person: the code to enter on its page, or an answer. */ const [signIn, setSignIn] = useState | null>(null) + /** What the agent needs before a send, from the sidecar's check. Null until the check answers, and Send waits. */ + const [needs, setNeeds] = useState(null) + /** The folder the chats from before the store could not be read from, when macOS keeps it from the app. */ + const [importBlocked, setImportBlocked] = useState(null) const sendingNow = useRef(false) const currentId = useRef(null) /** Bumped on every switch, so a slow load for a project already left is dropped. */ @@ -68,11 +78,12 @@ export default function StudioApp() { /** * Drafts by project, or `new` for the start screen, to fill the composer when a screen opens. The rules: typing saves * the draft of its screen once typing pauses, or at once when the person leaves the screen or sends. A send removes - * the draft only when it holds the sent text. Making a project moves the start screen's draft to it. + * the draft only when it holds the sent text. Making a project moves the start screen's draft to it. A draft keeps + * its pictures too, as the paths of their files. */ - const drafts = useRef>({}) - /** Typed text not saved yet, and the timer that saves it. */ - const unsaved = useRef<{ key: string; text: string; timer: number } | null>(null) + const drafts = useRef>({}) + /** A draft not saved yet, and the timer that saves it. */ + const unsaved = useRef<{ key: string; draft: Draft; timer: number } | null>(null) /** * Messages whose send has not gone through, by project. Sending one again sends the same pictures under the same * request id, so a send whose reply was lost never sends it twice. @@ -97,16 +108,39 @@ export default function StudioApp() { void checkForUpdatesAtLaunch() }, []) + /** Takes the app's saved state: projects, drafts, and whether the old chats could be read. */ + const takeState = (state: AppState) => { + // Drafts saved in this window since are newer, and text already typed stays. + drafts.current = { ...state.drafts, ...drafts.current } + // A project made while this was on its way is newer than the saved list, so it stays. + setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) + setImportBlocked(state.importBlocked) + if (currentId.current === null && !typedOnStart.current) { + const draft = drafts.current[START] + setText(draft?.text ?? '') + setAttachments((now) => (now.length === 0 ? (draft?.attachments ?? []).map(savedAttachment) : now)) + } + } + + /** Asks the sidecar what the agent still needs before a send. */ + const checkReady = () => { + void sidecar('setup.check', {}) + .then(setNeeds) + .catch((err: unknown) => setError(String(err))) + } + + const retryImport = () => { + void sidecar('legacy.import', {}) + .then(() => sidecar('state.get', {})) + .then(takeState) + .catch((err: unknown) => setError(String(err))) + } + useEffect(() => { sidecar('state.get', {}) - .then((state) => { - // Drafts saved in this window since are newer, and text already typed stays. - drafts.current = { ...state.drafts, ...drafts.current } - // A project made while this was on its way is newer than the saved list, so it stays. - setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) - if (currentId.current === null && !typedOnStart.current) setText(drafts.current[START]?.text ?? '') - }) + .then(takeState) .catch((err: unknown) => setError(String(err))) + checkReady() // The listener is in place before the ping, so no engine change is missed, and a ping that answers late never // hides one. let engineChanged = false @@ -143,7 +177,10 @@ export default function StudioApp() { setSignIn(event.code === undefined ? null : event) } if (event.step === 'ask') setSignIn(event) - if (event.step === 'done') setSignIn(null) + if (event.step === 'done') { + setSignIn(null) + checkReady() + } if (event.step === 'failed') { setSignIn(null) setError(event.detail) @@ -160,30 +197,50 @@ export default function StudioApp() { } }, []) - /** Saves the typed text now, if any waits to be saved. */ + /** Saves the draft now, if one waits to be saved. */ const saveDraft = async () => { - const draft = unsaved.current - if (draft === null) return - window.clearTimeout(draft.timer) + const waiting = unsaved.current + if (waiting === null) return + window.clearTimeout(waiting.timer) unsaved.current = null - drafts.current = { ...drafts.current, [draft.key]: { text: draft.text } } - await sidecar('drafts.set', { key: draft.key, text: draft.text, attachments: [] }) + drafts.current = { ...drafts.current, [waiting.key]: waiting.draft } + await sidecar('drafts.set', { key: waiting.key, ...waiting.draft }) + } + + // The composer's pictures as they are now, for a draft saved from outside a render, and to free their previews. + const unsent = useRef(attachments) + unsent.current = attachments + useEffect(() => () => unsent.current.forEach(releaseAttachment), []) + + /** Saves the open screen's draft once typing pauses: its text and its pictures. */ + const queueDraft = (text: string, pictures: Attachment[]) => { + if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) + const timer = window.setTimeout(() => void saveDraft().catch((err: unknown) => setError(String(err))), DRAFT_PAUSE_MS) + unsaved.current = { key: currentId.current ?? START, draft: { text, attachments: pictures.map((a) => a.path) }, timer } } const type = (value: string) => { edits.current += 1 setText(value) - if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) - const timer = window.setTimeout(() => void saveDraft().catch((err: unknown) => setError(String(err))), DRAFT_PAUSE_MS) - unsaved.current = { key: currentId.current ?? START, text: value, timer } + queueDraft(value, unsent.current) if (currentId.current === null) typedOnStart.current = true } useEffect(() => () => window.clearTimeout(unsaved.current?.timer), []) - // Pasted pictures still waiting when the studio closes give their previews back. - const unsent = useRef(attachments) - unsent.current = attachments - useEffect(() => () => unsent.current.forEach(releaseAttachment), []) + /** Changes the composer's pictures, and saves them with the draft. */ + const setPictures = (next: Attachment[]) => { + unsent.current = next + setAttachments(next) + queueDraft(composerText.current, next) + } + + // A quit or the window's close first saves the draft still waiting, then lets the quit go on. + useEffect(() => { + const unlisten = onQuitFlush(saveDraft) + return () => { + void unlisten.then((stop) => stop()) + } + }, []) // Pictures dropped anywhere on the window join the next message. useEffect(() => { @@ -194,7 +251,7 @@ export default function StudioApp() { setError('Drop a picture: PNG, JPG, HEIC, WebP, or GIF.') return } - setAttachments((prev) => [...prev, ...pictures.map((path) => ({ kind: 'path' as const, name: path.split(/[\\/]/).pop() ?? path, path }))]) + setPictures([...unsent.current, ...pictures.map(savedAttachment)]) }) return () => { void unlisten.then((stop) => stop()) @@ -228,9 +285,10 @@ export default function StudioApp() { const show = (project: Project | null, keepText = false) => { void saveDraft().catch((err: unknown) => setError(String(err))) if (!keepText) { - setText(drafts.current[project?.id ?? START]?.text ?? '') + const draft = drafts.current[project?.id ?? START] + setText(draft?.text ?? '') attachments.forEach(releaseAttachment) - setAttachments([]) + setAttachments((draft?.attachments ?? []).map(savedAttachment)) } loadRequest.current += 1 currentId.current = project?.id ?? null @@ -266,7 +324,7 @@ export default function StudioApp() { const send = async () => { const message = text.trim() - if ((!message && attachments.length === 0) || sendingNow.current || running) return + if ((!message && attachments.length === 0) || sendingNow.current || running || notReady) return sendingNow.current = true setSending(true) // The view this send belongs to. A move to another project or the start screen @@ -291,7 +349,7 @@ export default function StudioApp() { if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) unsaved.current = null if (typed.trim() !== message) { - drafts.current = { ...drafts.current, [created.id]: { text: typed }, [START]: { text: '' } } + drafts.current = { ...drafts.current, [created.id]: { text: typed, attachments: [] }, [START]: { text: '', attachments: [] } } for (const [key, value] of [[created.id, typed], [START, '']]) { void sidecar('drafts.set', { key, text: value, attachments: [] }).catch((err: unknown) => setError(String(err))) } @@ -315,9 +373,7 @@ export default function StudioApp() { : { text: message, outgoing, - images: await Promise.all( - outgoing.map((a) => (a.kind === 'path' ? studio.importFile(id, a.path) : studio.saveBytes(id, a.name, a.bytes))), - ), + images: await Promise.all(outgoing.map((a) => studio.importFile(id, a.path))), id: crypto.randomUUID(), } pending.current.set(id, sendable) @@ -327,7 +383,7 @@ export default function StudioApp() { // Text typed while this was sending is a new message, even one that reads the same, so the sent text is cleared // only when nothing was typed since, and only in the view that sent it. if (edits.current === sentEdits) { - if (drafts.current[id]?.text.trim() === message) drafts.current = { ...drafts.current, [id]: { text: '' } } + if (drafts.current[id]?.text.trim() === message) drafts.current = { ...drafts.current, [id]: { text: '', attachments: [] } } if (loadRequest.current === request) setText((now) => (now.trim() === message ? '' : now)) } // A picture added while these imported waits for the next message. @@ -354,23 +410,29 @@ export default function StudioApp() { if (current) setSendErrors(({ [current.id]: _, ...rest }) => rest) } + // Send waits for the check, and stays off while the agent lacks a sign-in, uv, or Python. + const notReady = needs === null || needs.length > 0 const composer = (placeholder: string, autoFocus: boolean) => ( - setAttachments((prev) => [...prev, ...more])} - onRemove={(index) => { - releaseAttachment(attachments[index]) - setAttachments((prev) => prev.filter((_, i) => i !== index)) - }} - onSend={() => void send()} - onStop={current ? () => void sidecar('chat.stop', { project: current.id }).catch((err: unknown) => setError(String(err))) : undefined} - busy={sending || running} - running={running} - placeholder={placeholder} - autoFocus={autoFocus} - /> + <> + {needs !== null && } + setPictures([...unsent.current, ...more])} + onPaste={(name, bytes) => sidecar('drafts.picture', { name, data: toBase64(bytes) })} + onRemove={(index) => { + releaseAttachment(attachments[index]) + setPictures(unsent.current.filter((_, i) => i !== index)) + }} + onSend={() => void send()} + onStop={current ? () => void sidecar('chat.stop', { project: current.id }).catch((err: unknown) => setError(String(err))) : undefined} + busy={sending || running || notReady} + running={running} + placeholder={placeholder} + autoFocus={autoFocus} + /> + ) return ( @@ -401,6 +463,7 @@ export default function StudioApp() { )} + {importBlocked !== null && } {signIn && ( = { anthropic: 'Sign in with Claude', 'opena * the turn picks up where it stopped on its own. */ export function WaitingBanner({ project, waiting, onError }: { project: string; waiting: Waiting; onError: (message: string) => void }) { - const [key, setKey] = useState('') - const [baseUrl, setBaseUrl] = useState('') const run = (work: Promise) => void work.catch((err: unknown) => onError(String(err))) if (waiting.kind === 'error') { @@ -25,10 +23,21 @@ export function WaitingBanner({ project, waiting, onError }: { project: string; ) } - const provider = waiting.provider as ProviderId return (
    {waiting.fix} + +
    + ) +} + +/** The ways to sign in to `provider`: its subscription sign-in, and a key or a proxy where it takes one. */ +function SignInControls({ provider, onError }: { provider: ProviderId; onError: (message: string) => void }) { + const [key, setKey] = useState('') + const [baseUrl, setBaseUrl] = useState('') + const run = (work: Promise) => void work.catch((err: unknown) => onError(String(err))) + return ( + <> {OAUTH[provider] !== undefined && ( )} + + ) +} + +/** + * What the agent needs before a message can go out, with the one thing that gives it: a sign-in, or uv or a Python + * to install. Send stays off until the check finds nothing missing. + */ +export function ReadinessBanner({ needs, onCheck, onError }: { needs: Need[]; onCheck: () => void; onError: (message: string) => void }) { + const [need] = needs + if (need === undefined) return null + return ( +
    + {need.fix} + {need.kind === 'login' && } + +
    + ) +} + +/** + * The chats from before the store sit in a folder macOS keeps from the app until the person allows it. Says how, opens + * the setting, and imports them once the person tries again. + */ +export function ImportBlockedBanner({ folder, onRetry, onError }: { folder: string; onRetry: () => void; onError: (message: string) => void }) { + return ( +
    + + Your earlier chats are in {folder}, and macOS has not let Materialize 3D read that folder. In Privacy & Security, open + Files & Folders and turn on Documents Folder for Materialize 3D, then try again. + + +
    ) } diff --git a/src/studio/api.ts b/src/studio/api.ts index 61875db1..2f988659 100644 --- a/src/studio/api.ts +++ b/src/studio/api.ts @@ -9,6 +9,13 @@ export const sidecar = (method: M, params: Methods[M]['params' export const onSidecarEvent = (handler: (event: SidecarEvent) => void) => listen('sidecar://event', ({ payload }) => handler(payload)) +/** Rust asks for this as the app quits or the window closes: save the draft now, then say it is done. */ +export const onQuitFlush = (save: () => Promise) => + listen('studio://flush', () => void save().finally(() => invoke('studio_flushed'))) + +/** Opens the macOS setting that lets the app read Documents. */ +export const openFilesPrivacy = () => invoke('studio_open_files_privacy') + export interface ProjectFile { rel: string size: number From 5a611072e5199f047501311a0f43124aa05f2267 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 14:52:44 -0600 Subject: [PATCH 34/59] chore(formal): record app.ts's hash after the import retry The old chats' import runs before the pipe opens, as before, and its retry does not touch the update gate. TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 1e22310a..d8e75acc 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -7,4 +7,4 @@ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/ adc22bee7f98a0717baf0999cccaf528142ad4e08e4ed88d17bedc45ec97d511 src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts 860fcae9bcc1f04f6a3de1352f13b2923c011bc8dbb91f52de5d8cc493172f30 sidecar/src/chat.ts -97028671a9e42763a9520730891ade82ac05d2beffe6fb251a471f709aa4e00e sidecar/src/app.ts +6596da86044ce6f0c138862e5abca7384bfd6cb46f9029ffd8e2ba15f717af3a sidecar/src/app.ts From 5baa2be7f88182a49f52439aec502682c3feea45 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 14:53:05 -0600 Subject: [PATCH 35/59] chore(tauri): regenerate the ACL schemas for studio_flushed and studio_open_files_privacy Co-Authored-By: Claude Opus 5.5 --- src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/gen/schemas/desktop-schema.json | 24 +++++++++++++++++++++++ src-tauri/gen/schemas/linux-schema.json | 24 +++++++++++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 293e8260..4dcdb2cf 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"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","allow-supports-multiple-windows"]},"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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"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-icon-with-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-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_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-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_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-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"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-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"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-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-flushed":{"identifier":"allow-studio-flushed","description":"Enables the studio_flushed command without any pre-configured scope.","commands":{"allow":["studio_flushed"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-files-privacy":{"identifier":"allow-studio-open-files-privacy","description":"Enables the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":["studio_open_files_privacy"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-flushed":{"identifier":"deny-studio-flushed","description":"Denies the studio_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flushed"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-files-privacy":{"identifier":"deny-studio-open-files-privacy","description":"Denies the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_files_privacy"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"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","allow-supports-multiple-windows"]},"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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"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-icon-with-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-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_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-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_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-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"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-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"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-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index bb422f39..2ab6e104 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-studio-flushed","allow-studio-open-files-privacy","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index cef9b018..9f2fc76a 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -770,12 +770,24 @@ "const": "allow-studio-files", "markdownDescription": "Enables the studio_files command without any pre-configured scope." }, + { + "description": "Enables the studio_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-flushed", + "markdownDescription": "Enables the studio_flushed command without any pre-configured scope." + }, { "description": "Enables the studio_import_file command without any pre-configured scope.", "type": "string", "const": "allow-studio-import-file", "markdownDescription": "Enables the studio_import_file command without any pre-configured scope." }, + { + "description": "Enables the studio_open_files_privacy command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-open-files-privacy", + "markdownDescription": "Enables the studio_open_files_privacy command without any pre-configured scope." + }, { "description": "Enables the studio_open_in_bambu command without any pre-configured scope.", "type": "string", @@ -1262,12 +1274,24 @@ "const": "deny-studio-files", "markdownDescription": "Denies the studio_files command without any pre-configured scope." }, + { + "description": "Denies the studio_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-flushed", + "markdownDescription": "Denies the studio_flushed command without any pre-configured scope." + }, { "description": "Denies the studio_import_file command without any pre-configured scope.", "type": "string", "const": "deny-studio-import-file", "markdownDescription": "Denies the studio_import_file command without any pre-configured scope." }, + { + "description": "Denies the studio_open_files_privacy command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-open-files-privacy", + "markdownDescription": "Denies the studio_open_files_privacy command without any pre-configured scope." + }, { "description": "Denies the studio_open_in_bambu command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json index cef9b018..9f2fc76a 100644 --- a/src-tauri/gen/schemas/linux-schema.json +++ b/src-tauri/gen/schemas/linux-schema.json @@ -770,12 +770,24 @@ "const": "allow-studio-files", "markdownDescription": "Enables the studio_files command without any pre-configured scope." }, + { + "description": "Enables the studio_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-flushed", + "markdownDescription": "Enables the studio_flushed command without any pre-configured scope." + }, { "description": "Enables the studio_import_file command without any pre-configured scope.", "type": "string", "const": "allow-studio-import-file", "markdownDescription": "Enables the studio_import_file command without any pre-configured scope." }, + { + "description": "Enables the studio_open_files_privacy command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-open-files-privacy", + "markdownDescription": "Enables the studio_open_files_privacy command without any pre-configured scope." + }, { "description": "Enables the studio_open_in_bambu command without any pre-configured scope.", "type": "string", @@ -1262,12 +1274,24 @@ "const": "deny-studio-files", "markdownDescription": "Denies the studio_files command without any pre-configured scope." }, + { + "description": "Denies the studio_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-flushed", + "markdownDescription": "Denies the studio_flushed command without any pre-configured scope." + }, { "description": "Denies the studio_import_file command without any pre-configured scope.", "type": "string", "const": "deny-studio-import-file", "markdownDescription": "Denies the studio_import_file command without any pre-configured scope." }, + { + "description": "Denies the studio_open_files_privacy command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-open-files-privacy", + "markdownDescription": "Denies the studio_open_files_privacy command without any pre-configured scope." + }, { "description": "Denies the studio_open_in_bambu command without any pre-configured scope.", "type": "string", From 82e2478f221d4a75218fbf8db90cb20cf0910736 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 14:59:23 -0600 Subject: [PATCH 36/59] fix(studio): only the newest chat load replaces the view, and a sent project's draft clears after edits elsewhere From CodeRabbit, each with a test that failed first. A chat.open that answered after the reload an engine restart made could put back an older chat. Each load now counts, and only the newest one applies. A send cleared the sent project's saved draft only when nothing was typed anywhere since, so typing in another project kept the sent text as that project's draft. The saved draft now goes unless a newer save for it still waits. The PATH reader test also counts a zombie writer as ended. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/sidecar.rs | 12 +++++++---- src/studio/StudioApp.test.tsx | 39 +++++++++++++++++++++++++++++++++++ src/studio/StudioApp.tsx | 12 +++++++---- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 979225ef..24cdd5d4 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -691,12 +691,16 @@ printf '__M3D_PATH__/opt/tools/bin__M3D_PATH__' std::fs::set_permissions(&shell, perms).unwrap(); assert_eq!(shell_path(shell.to_str().unwrap(), std::time::Duration::from_secs(5)).as_deref(), Some("/opt/tools/bin")); let pid = std::fs::read_to_string(&pid).unwrap().trim().parse::().unwrap(); - // Once nothing reads the pipe, the next write ends the writer. + // Once nothing reads the pipe, the next write ends the writer. An ended writer may wait as a zombie until the + // system reaps it, so that counts as ended too. + let ended = || { + let state = std::process::Command::new("ps").args(["-o", "stat=", "-p", &pid.to_string()]).output().expect("ps"); + let state = String::from_utf8_lossy(&state.stdout); + state.trim().is_empty() || state.trim_start().starts_with('Z') + }; let gone = (0..50).any(|_| { std::thread::sleep(std::time::Duration::from_millis(100)); - // SAFETY: kill with signal 0 only asks whether the process still exists. - let alive = unsafe { libc::kill(pid, 0) } == 0; - !alive + ended() }); if !gone { // SAFETY: ends the test's own writer, which outlived the lookup. diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index e0aacae8..b75e9870 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -309,6 +309,45 @@ describe('StudioApp', () => { expect(sidecarCalls('chat.open')).toHaveLength(2) }) + it('keeps the newer chat when an older load answers after the reload an engine restart made', async () => { + const asked = { id: 1, kind: 'user', text: 'A clip', images: [] } + const reply = { id: 2, kind: 'assistant', text: 'The clip is ready.', calls: [] } + let finishFirst: (view: unknown) => void = () => {} + let opens = 0 + mockSidecar({ + 'chat.open': (params) => + ++opens === 1 + ? new Promise((resolve) => (finishFirst = resolve)) + : Promise.resolve(view(String(params.project), { entries: [asked, reply], running: false, seq: 5 })), + }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + await emit({ type: 'ready', version: '1.2.3' }) + await screen.findByText('The clip is ready.') + await act(async () => finishFirst(view(projectB.id, { entries: [asked], running: true, seq: 1 }))) + expect(screen.getByText('The clip is ready.')).toBeTruthy() + expect(screen.queryByTestId('working')).toBeNull() + }) + + it("clears a sent project's saved draft even when the person typed in another project meanwhile", async () => { + let finishSend: (value: unknown) => void = () => {} + mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)) }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('something for A') + await act(async () => finishSend(null)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect(prompt().value).toBe('') + }) + it('keeps text typed during a send as the next message, even when it reads the same as the sent one', async () => { let finishSend: (value: unknown) => void = () => {} mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 26b82798..df802c5c 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -98,6 +98,8 @@ export default function StudioApp() { /** Counts the person's edits to the composer, so a send clears only the text it sent, not a later edit that reads the same. */ const edits = useRef(0) const early = useRef([]) + /** Counts chat loads, so only the newest load of the open chat replaces its view, not an older one that answers late. */ + const loads = useRef(0) composerText.current = text const items = useMemo(() => buildChat(view?.entries ?? [], view?.running ?? true), [view?.entries, view?.running]) @@ -305,8 +307,9 @@ export default function StudioApp() { /** Loads the open project's chat, with the changes that arrived while it loaded. */ const load = async (project: string, request: number) => { + const load = ++loads.current const opened = await sidecar('chat.open', { project }) - if (request !== loadRequest.current) return + if (request !== loadRequest.current || load !== loads.current) return setView(early.current.reduce(merge, opened)) early.current = [] loaded.current = project @@ -382,10 +385,11 @@ export default function StudioApp() { setSendErrors(({ [id]: _, ...rest }) => rest) // Text typed while this was sending is a new message, even one that reads the same, so the sent text is cleared // only when nothing was typed since, and only in the view that sent it. - if (edits.current === sentEdits) { - if (drafts.current[id]?.text.trim() === message) drafts.current = { ...drafts.current, [id]: { text: '', attachments: [] } } - if (loadRequest.current === request) setText((now) => (now.trim() === message ? '' : now)) + // The sent project's saved draft goes, unless a save of a newer draft for it still waits. + if (drafts.current[id]?.text.trim() === message && unsaved.current?.key !== id) { + drafts.current = { ...drafts.current, [id]: { text: '', attachments: [] } } } + if (edits.current === sentEdits && loadRequest.current === request) setText((now) => (now.trim() === message ? '' : now)) // A picture added while these imported waits for the next message. setAttachments((prev) => prev.filter((a) => !outgoing.includes(a))) outgoing.forEach(releaseAttachment) From 7fba96b5b5a526e43aa3d0021333cbd9c04456e6 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 14:59:24 -0600 Subject: [PATCH 37/59] chore(formal): record sidecar.rs's hash after a test change Only a test changed, so the models still describe the code. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index d8e75acc..dd286b17 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -4,7 +4,7 @@ bfdae9bc19239959afa97b28ed24be9289b35a988fb59ee657e5086babce1665 src-tauri/src/ 581b0e828a547528a87785cd366c6e57252dea85823cc1402fcaf7b113a2255c src-tauri/src/printer/mod.rs dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/commands.rs dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs -adc22bee7f98a0717baf0999cccaf528142ad4e08e4ed88d17bedc45ec97d511 src-tauri/src/sidecar.rs +b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts 860fcae9bcc1f04f6a3de1352f13b2923c011bc8dbb91f52de5d8cc493172f30 sidecar/src/chat.ts 6596da86044ce6f0c138862e5abca7384bfd6cb46f9029ffd8e2ba15f717af3a sidecar/src/app.ts From ab3907d1228c5f9bd18c580d9ded07519ee9f49e Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 15:11:47 -0600 Subject: [PATCH 38/59] fix(studio): Send waits on the check through a restart, drafts let go of sent pictures, and a quit waits for one save From the independent review of the acceptance fixes, with tests that failed first. - A readiness check that failed while the engine started again is asked again on ready, so Send does not stay off. A model whose provider the app does not offer is named as a need. - A send takes its pictures out of the draft waiting to be saved, the project's saved draft, and the start screen's, which a first message of only pictures leaves in place. The window now moves the start draft only by the sidecar's rule, the draft's text is the title. - A pasted picture joins only the screen it was pasted on. Saved pictures come back on the start screen even when the person typed before the saved state arrived. - A close or quit that comes while the window saves waits for the same save, then goes on too. - Pasted picture files no draft names are removed at the next start. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/app.ts | 11 ++++- sidecar/src/methods.ts | 14 ++++-- sidecar/src/protocol.ts | 1 + sidecar/test/switch.test.ts | 15 ++++++ sidecar/test/tools.test.ts | 9 ++++ src-tauri/src/studio/quit.rs | 60 +++++++++++++++++++----- src/studio/Chat.tsx | 19 ++++---- src/studio/StudioApp.test.tsx | 86 ++++++++++++++++++++++++++++++++++- src/studio/StudioApp.tsx | 67 +++++++++++++++++++++++---- 9 files changed, 245 insertions(+), 37 deletions(-) diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index 8d612a14..baba7d45 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -1,3 +1,4 @@ +import { existsSync, readdirSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { parseArgs } from 'node:util' import type { MutableModels } from '@earendil-works/pi-ai' @@ -99,6 +100,14 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis return { imported: imported.length, blocked } } let oldChats = await importOld() + // A pasted picture is kept until it is sent, which copies it into its project, or its draft lets it go. At the start + // no window has one pending, so each kept picture no draft names is removed. + const pictures = join(dirname(data), 'pictures') + const named = new Set(Object.values((await store.state()).drafts).flatMap((draft) => draft.attachments)) + for (const folder of existsSync(pictures) ? readdirSync(pictures) : []) { + const files = readdirSync(join(pictures, folder)) + if (!files.some((file) => named.has(join(pictures, folder, file)))) rmSync(join(pictures, folder), { recursive: true, force: true }) + } const legacy = { blocked: () => oldChats.blocked, retry: async () => { @@ -108,7 +117,7 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis } // The pipe opens before the pass is asked for and before the reconcile, which asks Rust's Keychain whether a // waited-on login is there now. - const serving = host.serve(handlers({ store, logins, chats, version, legacy, pictures: join(dirname(data), 'pictures') }), process.stdin) + const serving = host.serve(handlers({ store, logins, chats, version, legacy, pictures }), process.stdin) // Work a crash cut off resumes only with the pass. Without it an update is installing, and the work resumes after // the restart. const resumable = await busy.watch(store.harness) diff --git a/sidecar/src/methods.ts b/sidecar/src/methods.ts index 9f75f3f8..a87355eb 100644 --- a/sidecar/src/methods.ts +++ b/sidecar/src/methods.ts @@ -48,13 +48,17 @@ export function handlers({ store, logins, chats, version, legacy, pictures }: Se 'setup.check': { params: compile(obj({})), run: async () => { - // The chosen model's provider must have a login. A refused or expired one still waits in the turn, with its fix. + // The chosen model must be one the app offers, and its provider must have a login. A refused or expired login + // still waits in the turn, with its fix. const { settings } = await store.state() - const login = (await logins.status()).find((l) => l.provider === settings.model.provider) + const offered = await logins.status() + const login = offered.find((l) => l.provider === settings.model.provider) const needs: Need[] = - login !== undefined && login.login === null - ? [{ kind: 'login', provider: login.provider, name: login.name, fix: `Sign in to ${login.name} before you send.` }] - : [] + login === undefined + ? [{ kind: 'model', fix: `Choose a model from ${offered.map((l) => l.name).join(', ')}. The app does not offer ${settings.model.provider}.` }] + : login.login === null + ? [{ kind: 'login', provider: login.provider, name: login.name, fix: `Sign in to ${login.name} before you send.` }] + : [] return [...needs, ...toolchainNeeds()] }, }, diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index 43883290..b1ad4282 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -97,6 +97,7 @@ export type AppState = StoredState & { importBlocked: string | null } /** Something the agent needs before a message can go out, and the one thing that gives it. */ export type Need = | { kind: 'login'; provider: ProviderId; name: string; fix: string } + | { kind: 'model'; fix: string } | { kind: 'uv'; fix: string } | { kind: 'python'; fix: string } diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index af1b3148..4e402f19 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -52,6 +52,21 @@ describe('a picture pasted into a draft', () => { await expect(app.call('drafts.picture', { name: '../escape.png', data: '' })).rejects.toThrow() await app.quit() }) + + test('is removed at the next start once no draft keeps it, and kept while one does', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await first.next() + const kept = (await first.call('drafts.picture', { name: 'kept.png', data: '' })) as string + const dropped = (await first.call('drafts.picture', { name: 'dropped.png', data: '' })) as string + await first.call('drafts.set', { key: 'new', text: 'like this', attachments: [kept] }) + await first.quit() + const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await second.next() + expect(existsSync(kept)).toBe(true) + expect(existsSync(dropped)).toBe(false) + await second.quit() + }) }) describe('the import of chats from before the store', () => { diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index e8e661fa..50955513 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -153,6 +153,15 @@ describe('the check before a send', () => { expect((await check(toolchain(false), true)).map((need) => need.kind)).toEqual(['python']) }) + test('names a model whose provider the app does not offer', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-check-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { env: { PATH: toolchain(true) } }) + await app.next() + await app.call('settings.set', { model: { provider: 'openai', modelId: 'gpt-6' } }) + expect(((await app.call('setup.check', {})) as { kind: string }[]).map((need) => need.kind)).toEqual(['model']) + await app.quit() + }) + test('has nothing to name when the sign-in, uv, and Python are all there', async () => { expect(await check(toolchain(true), true)).toEqual([]) }) diff --git a/src-tauri/src/studio/quit.rs b/src-tauri/src/studio/quit.rs index 23c889dd..920ce373 100644 --- a/src-tauri/src/studio/quit.rs +++ b/src-tauri/src/studio/quit.rs @@ -2,7 +2,7 @@ //! saved yet. The window saves its draft a moment after typing pauses, so text typed just before a quit would be lost //! without this. A restart for an update cannot be held: Tauri ignores a hold on it, and the update waits for idle. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use std::time::Duration; use tauri::{AppHandle, Emitter, Manager, Runtime, State}; @@ -14,27 +14,56 @@ const FLUSH: &str = "studio://flush"; /// How long a quit waits for the window. A window that does not answer, as one that is gone, never holds it longer. const FLUSH_WAIT: Duration = Duration::from_millis(1500); +/// What runs once the window has saved: the quit, or the window's close, each held for the save. +type Held = Box; + +/// Where the save before a quit stands. +#[derive(Default)] +enum Stage { + #[default] + Idle, + /// The window was asked. Each quit or close that came meanwhile waits here for the same save. + Saving(Vec), + /// The window saved, or the wait ended. A quit or close now goes through. + Saved, +} + #[derive(Default)] pub struct QuitFlush { - /// Set once the window was asked, so the quit or close that follows the answer goes through. - asked: AtomicBool, + stage: Mutex, saved: Notify, } impl QuitFlush { - /// Asks the window to save its draft, the first time only, and then runs `then`. Returns whether it asked, so the - /// caller holds the quit or close until `then` runs. + /// Holds a quit or a close until the window has saved its draft, and then runs `then`. The first one asks the + /// window. One that comes while it saves waits for the same save. Returns whether it held, so the caller stops the + /// quit or close for now; once the window has saved, nothing is held. pub fn hold(app: &AppHandle, then: impl FnOnce(AppHandle) + Send + 'static) -> bool { let flush = app.state::(); - if flush.asked.swap(true, Ordering::SeqCst) { - return false; + let mut stage = flush.stage.lock().unwrap_or_else(|e| e.into_inner()); + let handle = app.clone(); + let held: Held = Box::new(move || then(handle)); + match &mut *stage { + Stage::Saved => return false, + Stage::Saving(waiting) => { + waiting.push(held); + return true; + } + Stage::Idle => *stage = Stage::Saving(vec![held]), } + drop(stage); let app = app.clone(); tauri::async_runtime::spawn(async move { if app.emit(FLUSH, ()).is_ok() { let _ = tokio::time::timeout(FLUSH_WAIT, app.state::().saved.notified()).await; } - then(app); + let held = match std::mem::replace(&mut *app.state::().stage.lock().unwrap_or_else(|e| e.into_inner()), Stage::Saved) { + Stage::Saving(held) => held, + Stage::Idle | Stage::Saved => Vec::new(), + }; + for then in held { + then(); + } }); true } @@ -72,11 +101,18 @@ mod tests { let app = tauri::test::mock_app(); app.manage(QuitFlush::default()); let (done, finished) = std::sync::mpsc::channel(); - let first = done.clone(); - assert!(QuitFlush::hold(app.handle(), move |_| first.send(()).unwrap()), "the first quit waits for the window"); - assert!(!QuitFlush::hold(app.handle(), move |_| done.send(()).unwrap()), "the quit that follows goes through"); + let (first, second) = (done.clone(), done.clone()); + assert!(QuitFlush::hold(app.handle(), move |_| first.send("close").unwrap()), "the close waits for the window"); + // A quit while the window still saves waits for the same save, then goes on too. + assert!(QuitFlush::hold(app.handle(), move |_| second.send("quit").unwrap()), "the quit waits for that save"); // No window answers in the mock, so the wait ends at its limit. - finished.recv_timeout(FLUSH_WAIT + Duration::from_secs(2)).expect("the quit goes on"); + let mut ran = [ + finished.recv_timeout(FLUSH_WAIT + Duration::from_secs(2)).expect("the close goes on"), + finished.recv_timeout(Duration::from_secs(1)).expect("the quit goes on"), + ]; + ran.sort(); + assert_eq!(ran, ["close", "quit"]); + assert!(!QuitFlush::hold(app.handle(), move |_| done.send("again").unwrap()), "once saved, a quit goes through"); } #[test] diff --git a/src/studio/Chat.tsx b/src/studio/Chat.tsx index 47161f33..4d4971a1 100644 --- a/src/studio/Chat.tsx +++ b/src/studio/Chat.tsx @@ -15,6 +15,9 @@ export type Attachment = { name: string; path: string; previewUrl?: string } export const savedAttachment = (path: string): Attachment => ({ name: path.split(/[\\/]/).pop() ?? path, path }) /** Frees a pasted picture's preview once it is sent, removed, or left. */ +/** A picture pasted into the composer, before it is kept in a file. */ +export type PastedPicture = { name: string; bytes: Uint8Array; previewUrl: string } + export function releaseAttachment(attachment: Attachment): void { if (attachment.previewUrl !== undefined) URL.revokeObjectURL(attachment.previewUrl) } @@ -109,8 +112,8 @@ interface ComposerProps { onText: (text: string) => void attachments: Attachment[] onAttach: (files: Attachment[]) => void - /** Keeps a pasted picture in a file, and answers its path. */ - onPaste: (name: string, bytes: Uint8Array) => Promise + /** Pictures pasted into the composer, with their previews, for the screen they were pasted on to keep. */ + onPaste: (pictures: PastedPicture[]) => void onRemove: (index: number) => void /** Sends the text and pictures. The owner clears them only once the send succeeds. */ onSend: () => void @@ -129,13 +132,13 @@ export function Composer({ text, onText, attachments, onAttach, onPaste, onRemov const pictures = [...event.clipboardData.files].filter((f) => f.type.startsWith('image/')) if (pictures.length === 0) return event.preventDefault() - onAttach( + onPaste( await Promise.all( - pictures.map(async (file, i) => { - const name = file.name && file.name !== 'image.png' ? file.name : `pasted-${Date.now()}-${i}.${file.type.split('/')[1] ?? 'png'}` - const path = await onPaste(name, new Uint8Array(await file.arrayBuffer())) - return { name, path, previewUrl: URL.createObjectURL(file) } - }), + pictures.map(async (file, i) => ({ + name: file.name && file.name !== 'image.png' ? file.name : `pasted-${Date.now()}-${i}.${file.type.split('/')[1] ?? 'png'}`, + bytes: new Uint8Array(await file.arrayBuffer()), + previewUrl: URL.createObjectURL(file), + })), ), ) } diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index b75e9870..f0a9790c 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -27,8 +27,10 @@ vi.mock('@tauri-apps/api/webview', () => ({ import StudioApp from './StudioApp' -// jsdom has no layout, so the chat's scroll to its newest line is a no-op here. +// jsdom has no layout, so the chat's scroll to its newest line is a no-op here, and no object URLs for previews. Element.prototype.scrollIntoView = vi.fn() +URL.createObjectURL = vi.fn(() => 'blob:preview') +URL.revokeObjectURL = vi.fn() const projectA = { id: '2026-11-06-a', conversation: 2, title: 'Project A', createdMs: 2 } const projectB = { id: '2026-11-06-b', conversation: 3, title: 'Project B', createdMs: 1 } @@ -277,6 +279,88 @@ describe('StudioApp', () => { expect(await screen.findByText('Project A')).toBeTruthy() }) + it('checks again when the engine is ready, so a check that failed during a restart does not keep Send off', async () => { + let checks = 0 + mockSidecar({ 'setup.check': () => (++checks === 1 ? Promise.reject('The engine restarted before it answered.') : Promise.resolve([])) }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('A clip') + expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(true) + await emit({ type: 'ready', version: '1.2.3' }) + await waitFor(() => expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(false)) + }) + + it('never brings a sent picture back in the next draft', async () => { + let finishSend: (value: unknown) => void = () => {} + mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), studio_import_file: () => Promise.resolve('refs/photo.jpg') }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + type('Like this') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + type('And thinner') + await act(async () => finishSend(null)) + await new Promise((resolve) => setTimeout(resolve, 500)) + const saves = sidecarCalls('drafts.set') + expect(saves[saves.length - 1]).toEqual({ key: projectB.id, text: 'And thinner', attachments: [] }) + fireEvent.click(screen.getByText('Project A')) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + + it('takes a picture sent as a first message off the start screen', async () => { + mockSidecar({ + 'projects.create': () => Promise.resolve({ id: '2026-11-06-new-part', conversation: 4, title: 'New part', createdMs: 3 }), + studio_import_file: () => Promise.resolve('refs/photo.jpg'), + }) + render() + await screen.findByText('Project B') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + fireEvent.click(screen.getByTestId('new-project')) + expect(screen.queryByTestId('attachments')).toBeNull() + await waitFor(() => { + const saves = sidecarCalls('drafts.set').filter((save) => save.key === 'new') + expect(saves[saves.length - 1]).toEqual({ key: 'new', text: '', attachments: [] }) + }) + }) + + it('keeps a pasted picture on the screen it was pasted on, even when the person leaves before it is kept', async () => { + let finishPicture: (path: unknown) => void = () => {} + mockSidecar({ 'drafts.picture': () => new Promise((resolve) => (finishPicture = resolve)) }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + const file = new File(['png'], 'shot.png', { type: 'image/png' }) + fireEvent.paste(prompt(), { clipboardData: { files: [file] } }) + await waitFor(() => expect(sidecarCalls('drafts.picture')).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await act(async () => finishPicture('/state/pictures/1/shot.png')) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + + it("brings the start screen's saved pictures back even when the person typed before the saved state arrived", async () => { + let finishState: (state: unknown) => void = () => {} + mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) + render() + type('a hook') + await act(async () => + finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'] } }, importBlocked: null }), + ) + expect(screen.getByTestId('attachments').textContent).toContain('hook.jpg') + expect(prompt().value).toBe('a hook') + await waitFor(() => { + const saves = sidecarCalls('drafts.set') + expect(saves[saves.length - 1]).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] }) + }) + }) + it('keeps chat changes that arrive while the chat opens', async () => { let finishOpen: (view: unknown) => void = () => {} mockSidecar({ 'chat.open': () => new Promise((resolve) => (finishOpen = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index df802c5c..baa7d602 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -3,7 +3,7 @@ import { openUrl } from '@tauri-apps/plugin-opener' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AppState, Draft, LoginEvent, Need, ChatView as View, Project } from '../../sidecar/src/protocol' import { autoOpens, onQuitFlush, onSidecarEvent, sidecar, studio, type ProjectFile } from './api' -import { ChatView, Composer, releaseAttachment, savedAttachment, type Attachment } from './Chat' +import { ChatView, Composer, releaseAttachment, savedAttachment, type Attachment, type PastedPicture } from './Chat' import RightPanel, { type Tab } from './RightPanel' import { buildChat } from './transcript' import { ImportBlockedBanner, ReadinessBanner, SignInAsk, WaitingBanner } from './Waiting' @@ -117,10 +117,19 @@ export default function StudioApp() { // A project made while this was on its way is newer than the saved list, so it stays. setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) setImportBlocked(state.importBlocked) - if (currentId.current === null && !typedOnStart.current) { + if (currentId.current === null) { const draft = drafts.current[START] - setText(draft?.text ?? '') - setAttachments((now) => (now.length === 0 ? (draft?.attachments ?? []).map(savedAttachment) : now)) + if (!typedOnStart.current) setText(draft?.text ?? '') + // Pictures the person has not touched yet come back even when they typed first. A save that waits for that typing + // then keeps them too. + const restored = (draft?.attachments ?? []).map(savedAttachment) + if (unsent.current.length === 0 && restored.length > 0) { + if (unsaved.current?.key === START) setPictures(restored) + else { + unsent.current = restored + setAttachments(restored) + } + } } } @@ -151,6 +160,8 @@ export default function StudioApp() { case 'ready': { engineChanged = true setEngine(`Engine ${event.version}`) + // A check that failed while the engine started again is asked again, so Send does not stay off. + checkReady() // An engine that started again holds what a crash cut off from the window, such as a turn's last entry, so // the open chat is read again. The changes that arrive meanwhile are kept, as when it first opened. const project = currentId.current @@ -236,6 +247,24 @@ export default function StudioApp() { queueDraft(composerText.current, next) } + /** Keeps pasted pictures in files, then adds them to the screen they were pasted on, if the person is still there. */ + const pastePictures = (pasted: PastedPicture[]) => { + const request = loadRequest.current + void Promise.all(pasted.map(({ name, bytes }) => sidecar('drafts.picture', { name, data: toBase64(bytes) }))) + .then((paths) => { + const added = pasted.map(({ name, previewUrl }, i) => ({ name, path: paths[i] ?? '', previewUrl })) + if (request !== loadRequest.current) { + added.forEach(releaseAttachment) + return + } + setPictures([...unsent.current, ...added]) + }) + .catch((err: unknown) => { + pasted.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl)) + setError(String(err)) + }) + } + // A quit or the window's close first saves the draft still waiting, then lets the quit go on. useEffect(() => { const unlisten = onQuitFlush(saveDraft) @@ -339,11 +368,13 @@ export default function StudioApp() { const sentEdits = edits.current let project = current if (!project) { - const created = await sidecar('projects.create', { title: message || 'New part' }) + const title = message || 'New part' + const created = await sidecar('projects.create', { title }) project = created - // The sidecar moved the start screen's draft to the new project in the same commit, when it held this message. + // The sidecar moved the start screen's draft to the new project in the same commit when the draft's text is the + // project's title, as for a first message with text. One of only pictures stays on the start screen. const { [START]: started, ...rest } = drafts.current - if (started?.text.trim() === message) drafts.current = { ...rest, [created.id]: started } + if (started !== undefined && started.text.trim() === title) drafts.current = { ...rest, [created.id]: started } setProjects((prev) => [created, ...prev]) if (loadRequest.current === request) { // The person stayed on the screen this was sent from, so text typed while the project was made follows them @@ -390,8 +421,24 @@ export default function StudioApp() { drafts.current = { ...drafts.current, [id]: { text: '', attachments: [] } } } if (edits.current === sentEdits && loadRequest.current === request) setText((now) => (now.trim() === message ? '' : now)) - // A picture added while these imported waits for the next message. - setAttachments((prev) => prev.filter((a) => !outgoing.includes(a))) + // The sent pictures leave every draft that names them: the one waiting to be saved, the saved one of this + // project, and the start screen's, which a first message of only pictures leaves in place. A picture added + // while these imported waits for the next message. + const sent = new Set(outgoing.map((a) => a.path)) + const unsentNames = (draft: Draft) => draft.attachments.filter((path) => !sent.has(path)) + if (unsaved.current !== null) unsaved.current.draft = { ...unsaved.current.draft, attachments: unsentNames(unsaved.current.draft) } + for (const key of [id, START]) { + const draft = drafts.current[key] + if (draft === undefined || draft.attachments.every((path) => !sent.has(path))) continue + drafts.current = { ...drafts.current, [key]: { ...draft, attachments: unsentNames(draft) } } + if (unsaved.current?.key !== key) { + void sidecar('drafts.set', { key, ...drafts.current[key] }).catch((err: unknown) => setError(String(err))) + } + } + if (loadRequest.current === request) { + unsent.current = unsent.current.filter((a) => !outgoing.includes(a)) + setAttachments(unsent.current) + } outgoing.forEach(releaseAttachment) } catch (err) { const message = String(err) @@ -424,7 +471,7 @@ export default function StudioApp() { onText={type} attachments={attachments} onAttach={(more) => setPictures([...unsent.current, ...more])} - onPaste={(name, bytes) => sidecar('drafts.picture', { name, data: toBase64(bytes) })} + onPaste={pastePictures} onRemove={(index) => { releaseAttachment(attachments[index]) setPictures(unsent.current.filter((_, i) => i !== index)) From 7e9a7bd888748e67b4e38f5598ba9803d6c717b0 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 15:12:04 -0600 Subject: [PATCH 39/59] chore(formal): record app.ts's hash after the picture cleanup Removing unused pasted pictures at the start does not touch the update gate. TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index dd286b17..454406c9 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -7,4 +7,4 @@ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/ b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts 860fcae9bcc1f04f6a3de1352f13b2923c011bc8dbb91f52de5d8cc493172f30 sidecar/src/chat.ts -6596da86044ce6f0c138862e5abca7384bfd6cb46f9029ffd8e2ba15f717af3a sidecar/src/app.ts +f61e19e249c92420636345eb111d832763d83390ecb87031fe08b03ff8dd90a4 sidecar/src/app.ts From 6a4c9a60fec4c0e25663d9b7098b82f8e399370d Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 15:24:16 -0600 Subject: [PATCH 40/59] fix(studio): a send touches only its own drafts, keeps newer ones, and a paste stays where it began From the second review round, each with a test that failed first. - Only pictures older than a day that no draft names are pruned at the start, so a window that outlived a crash keeps a picture it has not saved in a draft yet. - A send takes its pictures only from its own project's drafts, and from the start screen's when it made the project from it, never from another project's draft of the same file. - A send clears its project's saved draft only while it is still the draft the send went out with, so a newer draft that reads the same, saved meanwhile, stays with its pictures. - A paste is handed over before its bytes are read, so it stays on the screen it was pasted on. - The start screen's saved pictures come back when the person typed there first, even after that typing was saved. - A send takes its pictures out of its project's composer whenever that project is open, even after a visit away. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/app.ts | 13 +++-- sidecar/test/switch.test.ts | 13 +++-- src/studio/Chat.tsx | 16 +++---- src/studio/StudioApp.test.tsx | 90 +++++++++++++++++++++++++++++++++++ src/studio/StudioApp.tsx | 57 ++++++++++++++-------- 5 files changed, 152 insertions(+), 37 deletions(-) diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index baba7d45..af21b7df 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, rmSync } from 'node:fs' +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' import { dirname, join } from 'node:path' import { parseArgs } from 'node:util' import type { MutableModels } from '@earendil-works/pi-ai' @@ -100,13 +100,16 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis return { imported: imported.length, blocked } } let oldChats = await importOld() - // A pasted picture is kept until it is sent, which copies it into its project, or its draft lets it go. At the start - // no window has one pending, so each kept picture no draft names is removed. + // A pasted picture is kept until it is sent, which copies it into its project, or its draft lets it go. A window that + // outlived a crash may still hold one it has not saved in a draft yet, so only pictures older than a day that no + // draft names are removed. const pictures = join(dirname(data), 'pictures') const named = new Set(Object.values((await store.state()).drafts).flatMap((draft) => draft.attachments)) + const dayAgo = Date.now() - 86_400_000 for (const folder of existsSync(pictures) ? readdirSync(pictures) : []) { - const files = readdirSync(join(pictures, folder)) - if (!files.some((file) => named.has(join(pictures, folder, file)))) rmSync(join(pictures, folder), { recursive: true, force: true }) + const path = join(pictures, folder) + if (statSync(path).mtimeMs > dayAgo) continue + if (!readdirSync(path).some((file) => named.has(join(path, file)))) rmSync(path, { recursive: true, force: true }) } const legacy = { blocked: () => oldChats.blocked, diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index 4e402f19..a49a7d8d 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' -import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import type { ChatEntry, ChatView } from '../src/protocol' import { spawnSidecar } from './sidecar' @@ -64,8 +64,15 @@ describe('a picture pasted into a draft', () => { const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) await second.next() expect(existsSync(kept)).toBe(true) - expect(existsSync(dropped)).toBe(false) + // A picture from a moment ago may still wait in a window that outlived a crash, so only old ones go. + expect(existsSync(dropped)).toBe(true) await second.quit() + utimesSync(dirname(dropped), new Date(Date.now() - 2 * 86_400_000), new Date(Date.now() - 2 * 86_400_000)) + const third = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await third.next() + expect(existsSync(kept)).toBe(true) + expect(existsSync(dropped)).toBe(false) + await third.quit() }) }) diff --git a/src/studio/Chat.tsx b/src/studio/Chat.tsx index 4d4971a1..4dfcd001 100644 --- a/src/studio/Chat.tsx +++ b/src/studio/Chat.tsx @@ -16,7 +16,7 @@ export const savedAttachment = (path: string): Attachment => ({ name: path.split /** Frees a pasted picture's preview once it is sent, removed, or left. */ /** A picture pasted into the composer, before it is kept in a file. */ -export type PastedPicture = { name: string; bytes: Uint8Array; previewUrl: string } +export type PastedPicture = { name: string; file: File } export function releaseAttachment(attachment: Attachment): void { if (attachment.previewUrl !== undefined) URL.revokeObjectURL(attachment.previewUrl) @@ -128,18 +128,16 @@ interface ComposerProps { export function Composer({ text, onText, attachments, onAttach, onPaste, onRemove, onSend, onStop, busy, running, placeholder, autoFocus }: ComposerProps) { const empty = !text.trim() && attachments.length === 0 - const paste = async (event: React.ClipboardEvent) => { + const paste = (event: React.ClipboardEvent) => { const pictures = [...event.clipboardData.files].filter((f) => f.type.startsWith('image/')) if (pictures.length === 0) return event.preventDefault() + // Handed over at once, before any wait, so the pictures stay with the screen they were pasted on. onPaste( - await Promise.all( - pictures.map(async (file, i) => ({ - name: file.name && file.name !== 'image.png' ? file.name : `pasted-${Date.now()}-${i}.${file.type.split('/')[1] ?? 'png'}`, - bytes: new Uint8Array(await file.arrayBuffer()), - previewUrl: URL.createObjectURL(file), - })), - ), + pictures.map((file, i) => ({ + name: file.name && file.name !== 'image.png' ? file.name : `pasted-${Date.now()}-${i}.${file.type.split('/')[1] ?? 'png'}`, + file, + })), ) } diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index f0a9790c..3382bf69 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -361,6 +361,96 @@ describe('StudioApp', () => { }) }) + it("leaves another project's same picture alone when a send goes through", async () => { + let finishSend: (value: unknown) => void = () => {} + mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), studio_import_file: () => Promise.resolve('refs/photo.jpg') }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + type('Like this') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + await act(async () => finishSend(null)) + await new Promise((resolve) => setTimeout(resolve, 500)) + const saves = sidecarCalls('drafts.set').filter((save) => save.key === projectB.id) + expect(saves[saves.length - 1]).toEqual({ key: projectB.id, text: '', attachments: ['/Users/me/photo.jpg'] }) + }) + + it('keeps a newer saved draft that reads the same as the sent text', async () => { + let finishSend: (value: unknown) => void = () => {} + mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), studio_import_file: () => Promise.resolve('refs/a.jpg') }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + type('Make it blue') + type('Make it red') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/new.jpg'] } }))) + await new Promise((resolve) => setTimeout(resolve, 500)) + await act(async () => finishSend(null)) + fireEvent.click(screen.getByText('Project A')) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect(prompt().value).toBe('Make it red') + expect(screen.getByTestId('attachments').textContent).toContain('new.jpg') + }) + + it('keeps a paste on its screen even when the person leaves while its bytes are read', async () => { + mockSidecar({ 'drafts.picture': () => Promise.resolve('/state/pictures/1/shot.png') }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + let finishRead: (bytes: ArrayBuffer) => void = () => {} + const file = new File(['png'], 'shot.png', { type: 'image/png' }) + file.arrayBuffer = () => new Promise((resolve) => (finishRead = resolve)) + fireEvent.paste(prompt(), { clipboardData: { files: [file] } }) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await act(async () => finishRead(new ArrayBuffer(3))) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + + it("brings the start screen's saved pictures back when the person typed and paused before the saved state arrived", async () => { + let finishState: (state: unknown) => void = () => {} + mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) + render() + type('a hook') + await new Promise((resolve) => setTimeout(resolve, 500)) + await act(async () => + finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'] } }, importBlocked: null }), + ) + expect(screen.getByTestId('attachments').textContent).toContain('hook.jpg') + await waitFor(() => { + const saves = sidecarCalls('drafts.set') + expect(saves[saves.length - 1]).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] }) + }) + }) + + it('takes the sent pictures out of the composer when the person left and came back before the send went through', async () => { + let finishSend: (value: unknown) => void = () => {} + mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), studio_import_file: () => Promise.resolve('refs/photo.jpg') }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + type('Like this') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + await act(async () => finishSend(null)) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + it('keeps chat changes that arrive while the chat opens', async () => { let finishOpen: (view: unknown) => void = () => {} mockSidecar({ 'chat.open': () => new Promise((resolve) => (finishOpen = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index baa7d602..0a6453ea 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -91,6 +91,8 @@ export default function StudioApp() { const pending = useRef(new Map()) /** Whether the person typed on the start screen, so the saved draft no longer fills it. */ const typedOnStart = useRef(false) + /** Whether the person changed the start screen's pictures, so its saved ones no longer fill it. */ + const startPicturesTouched = useRef(false) /** The composer's text as it is now, for a send that finishes after the person typed more. */ const composerText = useRef('') /** The project whose chat view is loaded. Chat changes for the open project wait in `early` until it is. */ @@ -112,8 +114,13 @@ export default function StudioApp() { /** Takes the app's saved state: projects, drafts, and whether the old chats could be read. */ const takeState = (state: AppState) => { - // Drafts saved in this window since are newer, and text already typed stays. + // Drafts saved in this window since are newer, and text already typed stays. The start screen's saved pictures + // still come back when the person only typed there, even if that typing was saved. + const saved = state.drafts[START] + const local = drafts.current[START] + const restoreStart = !startPicturesTouched.current && saved !== undefined && saved.attachments.length > 0 && (local?.attachments.length ?? 0) === 0 drafts.current = { ...state.drafts, ...drafts.current } + if (restoreStart) drafts.current = { ...drafts.current, [START]: { text: local?.text ?? saved.text, attachments: saved.attachments } } // A project made while this was on its way is newer than the saved list, so it stays. setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) setImportBlocked(state.importBlocked) @@ -124,7 +131,8 @@ export default function StudioApp() { // then keeps them too. const restored = (draft?.attachments ?? []).map(savedAttachment) if (unsent.current.length === 0 && restored.length > 0) { - if (unsaved.current?.key === START) setPictures(restored) + // Typing already saved, or waiting to be, gets the pictures too. + if (typedOnStart.current) setPictures(restored) else { unsent.current = restored setAttachments(restored) @@ -242,6 +250,7 @@ export default function StudioApp() { /** Changes the composer's pictures, and saves them with the draft. */ const setPictures = (next: Attachment[]) => { + if (currentId.current === null) startPicturesTouched.current = true unsent.current = next setAttachments(next) queueDraft(composerText.current, next) @@ -250,19 +259,18 @@ export default function StudioApp() { /** Keeps pasted pictures in files, then adds them to the screen they were pasted on, if the person is still there. */ const pastePictures = (pasted: PastedPicture[]) => { const request = loadRequest.current - void Promise.all(pasted.map(({ name, bytes }) => sidecar('drafts.picture', { name, data: toBase64(bytes) }))) - .then((paths) => { - const added = pasted.map(({ name, previewUrl }, i) => ({ name, path: paths[i] ?? '', previewUrl })) - if (request !== loadRequest.current) { - added.forEach(releaseAttachment) - return - } - setPictures([...unsent.current, ...added]) - }) - .catch((err: unknown) => { - pasted.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl)) - setError(String(err)) + void Promise.all( + pasted.map(async ({ name, file }) => ({ + name, + path: await sidecar('drafts.picture', { name, data: toBase64(new Uint8Array(await file.arrayBuffer())) }), + file, + })), + ) + .then((kept) => { + if (request !== loadRequest.current) return + setPictures([...unsent.current, ...kept.map(({ name, path, file }) => ({ name, path, previewUrl: URL.createObjectURL(file) }))]) }) + .catch((err: unknown) => setError(String(err))) } // A quit or the window's close first saves the draft still waiting, then lets the quit go on. @@ -366,6 +374,10 @@ export default function StudioApp() { try { await saveDraft() const sentEdits = edits.current + // The saved draft this send went out with: the open project's, or the start screen's, which may move to the new + // project. A save after it is a newer draft, which the send leaves alone. + const sentDraft = drafts.current[current?.id ?? START] + const createdHere = current === null let project = current if (!project) { const title = message || 'New part' @@ -416,8 +428,8 @@ export default function StudioApp() { setSendErrors(({ [id]: _, ...rest }) => rest) // Text typed while this was sending is a new message, even one that reads the same, so the sent text is cleared // only when nothing was typed since, and only in the view that sent it. - // The sent project's saved draft goes, unless a save of a newer draft for it still waits. - if (drafts.current[id]?.text.trim() === message && unsaved.current?.key !== id) { + // The sent project's saved draft goes, unless a newer draft for it was saved since, or waits to be. + if (sentDraft !== undefined && drafts.current[id] === sentDraft && unsaved.current?.key !== id) { drafts.current = { ...drafts.current, [id]: { text: '', attachments: [] } } } if (edits.current === sentEdits && loadRequest.current === request) setText((now) => (now.trim() === message ? '' : now)) @@ -426,8 +438,12 @@ export default function StudioApp() { // while these imported waits for the next message. const sent = new Set(outgoing.map((a) => a.path)) const unsentNames = (draft: Draft) => draft.attachments.filter((path) => !sent.has(path)) - if (unsaved.current !== null) unsaved.current.draft = { ...unsaved.current.draft, attachments: unsentNames(unsaved.current.draft) } - for (const key of [id, START]) { + // Only this send's drafts: its project's, and the start screen's when this send made the project from it. + const keys = createdHere ? [id, START] : [id] + if (unsaved.current !== null && keys.includes(unsaved.current.key)) { + unsaved.current.draft = { ...unsaved.current.draft, attachments: unsentNames(unsaved.current.draft) } + } + for (const key of keys) { const draft = drafts.current[key] if (draft === undefined || draft.attachments.every((path) => !sent.has(path))) continue drafts.current = { ...drafts.current, [key]: { ...draft, attachments: unsentNames(draft) } } @@ -435,8 +451,9 @@ export default function StudioApp() { void sidecar('drafts.set', { key, ...drafts.current[key] }).catch((err: unknown) => setError(String(err))) } } - if (loadRequest.current === request) { - unsent.current = unsent.current.filter((a) => !outgoing.includes(a)) + // The composer of the sent project, if it is open now, even after a visit elsewhere brought its draft back. + if (currentId.current === id) { + unsent.current = unsent.current.filter((a) => !sent.has(a.path)) setAttachments(unsent.current) } outgoing.forEach(releaseAttachment) From 7d6dee948cdb14dc550dd63d6fe1cbfdbb7d39e7 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 15:24:34 -0600 Subject: [PATCH 41/59] chore(formal): record app.ts's hash after the picture age check Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 454406c9..112c6a28 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -7,4 +7,4 @@ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/ b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts 860fcae9bcc1f04f6a3de1352f13b2923c011bc8dbb91f52de5d8cc493172f30 sidecar/src/chat.ts -f61e19e249c92420636345eb111d832763d83390ecb87031fe08b03ff8dd90a4 sidecar/src/app.ts +effb5c139b839c110f95d3f996b40cee7fbe4f1894a86fb2fb1b8d7d6a70c6d8 sidecar/src/app.ts From 1ac29917de5681a1bb488cfac356373070e04be5 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 15:38:35 -0600 Subject: [PATCH 42/59] fix(studio): drafts carry a revision, and a send consumes only the draft it went out with Three review rounds kept finding races where the window guessed after a send which drafts it had consumed, by text, path, or object identity, while the sidecar deleted a project's draft by matching its text. Each draft save now gets a revision from the sidecar, which only grows. A send names the revision it went out with, and the sidecar removes the draft only while it still has that revision, so a newer draft saved meanwhile stays, even one that reads the same. In the window, edits are counted per screen: after a send, a screen clears only when nothing changed there since it went out. A changed screen keeps its newer draft, and only the picture files just sent leave it. A first message of only pictures, which the sidecar does not move, leaves the start screen the same way. Tests for each of the last round's findings failed on the old code. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 11 +-- sidecar/src/methods.ts | 13 ++-- sidecar/src/protocol.ts | 17 ++++- sidecar/src/store.ts | 18 +++-- sidecar/test/agent.test.ts | 25 ++++--- sidecar/test/store.test.ts | 4 +- sidecar/test/switch.test.ts | 2 +- src/studio/StudioApp.test.tsx | 78 +++++++++++++++++++++ src/studio/StudioApp.tsx | 126 +++++++++++++++++++++++----------- 9 files changed, 218 insertions(+), 76 deletions(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 0fbf02ec..bb098758 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -203,11 +203,11 @@ export class Chats { } /** - * Sends a message. The message, the settings its turn runs with, and the removal of the draft that holds it commit - * together. A send whose `requestId` was already committed changes nothing, so the window can send again after any + * Sends a message. The message, the settings its turn runs with, and the removal of the draft it went out with + * (`draftRev`) commit together. A send whose `requestId` was already committed changes nothing, so the window can send again after any * failure, even once a picture it sent is gone. */ - async send(project: string, requestId: string, text: string, attachments: string[]): Promise { + async send(project: string, requestId: string, text: string, attachments: string[], draftRev: number | undefined): Promise { const { store, extension, tools, version, busy } = this.#o const conversation = await this.#conversation(project) if ((await store.harness.commit((tx) => tx.submissionByRequest(conversation.id, requestId), context)) !== undefined) { @@ -232,9 +232,10 @@ export class Chats { await configure(tx, conversation.id, { model, thinkingLevel: thinking, extensions: [extension], tools, instructions: WORKFLOW, cwd: folder }) const turn: Turn = { requestId, model, thinking, tools: tools.map((t) => t.name), instructions: WORKFLOW_VERSION, app: version, startedMs: Date.now() } ;(await tx.doc(TurnDoc, conversation.id)).turn = turn - // Text typed after this message was sent is a newer message, so only a draft of this text goes. + // Only the draft this message went out with goes. One saved after it is the next message, even one that reads + // the same, so it stays. const { drafts } = await tx.doc(DraftsDoc, store.root.id) - if (drafts[project]?.text.trim() === text.trim()) delete drafts[project] + if (draftRev !== undefined && drafts[project]?.rev === draftRev) delete drafts[project] const waiting = await tx.doc(WaitingDoc, conversation.id) waiting.waiting = null waiting.tool = false diff --git a/sidecar/src/methods.ts b/sidecar/src/methods.ts index a87355eb..8d6e7a04 100644 --- a/sidecar/src/methods.ts +++ b/sidecar/src/methods.ts @@ -1,6 +1,6 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { basename, join } from 'node:path' -import { arr, bool, compile, lit, obj, opt, str, union } from '@earendil-works/pi-schema' +import { arr, bool, compile, int, lit, obj, opt, str, union } from '@earendil-works/pi-schema' import { toolchainNeeds } from './agent' import type { Chats } from './chat' import type { Handlers } from './host' @@ -77,10 +77,7 @@ export function handlers({ store, logins, chats, version, legacy, pictures }: Se }, 'drafts.set': { params: compile(obj({ key: str(), text: str(), attachments: arr(str()) })), - run: async ({ key, text, attachments }) => { - await store.setDraft(key, { text, attachments }) - return null - }, + run: async ({ key, text, attachments }) => ({ rev: await store.setDraft(key, { text, attachments }) }), }, 'projects.create': { params: compile(obj({ title: str() })), run: ({ title }) => store.createProject(title) }, 'auth.status': { params: compile(obj({})), run: () => logins.status() }, @@ -114,9 +111,9 @@ export function handlers({ store, logins, chats, version, legacy, pictures }: Se }, 'chat.open': { params: compile(obj({ project: str() })), run: ({ project }) => chats.open(project) }, 'chat.send': { - params: compile(obj({ project: str(), requestId: str({ minLength: 1 }), text: str(), attachments: arr(str()) })), - run: async ({ project, requestId, text, attachments }) => { - await chats.send(project, requestId, text, attachments) + params: compile(obj({ project: str(), requestId: str({ minLength: 1 }), text: str(), attachments: arr(str()), draftRev: opt(int()) })), + run: async ({ project, requestId, text, attachments, draftRev }) => { + await chats.send(project, requestId, text, attachments, draftRev) return null }, }, diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index b1ad4282..ce3bb49d 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -25,6 +25,12 @@ export type Project = { id: string; conversation: number; title: string; created /** An unsent message, by project id, or `new` for the start screen. Attachments are paths inside the project. */ export type Draft = { text: string; attachments: string[] } +/** + * A draft as the store keeps it. `rev` grows with every save of any draft, and is never used again, so a send can name + * the draft it went out with, and a newer one saved meanwhile is never taken for it. + */ +export type SavedDraft = Draft & { rev: number } + /** How a provider's login was made: an API key or proxy token, or a subscription sign-in. */ export type LoginKind = 'api_key' | 'oauth' @@ -86,7 +92,7 @@ export type Turn = { export type ChatView = { project: string; entries: ChatEntry[]; running: boolean; waiting: Waiting | null; turn: Turn | null; seq: number } /** What the store keeps of the app. */ -export type StoredState = { setup: Setup; settings: Settings; projects: Project[]; drafts: Record } +export type StoredState = { setup: Setup; settings: Settings; projects: Project[]; drafts: Record } /** * The app as the window sees it. `importBlocked` names the projects folder when the chats from before the store could @@ -107,7 +113,8 @@ export interface Methods { 'state.get': { params: Record; result: AppState } 'setup.set': { params: Setup; result: AppState } 'settings.set': { params: Partial; result: AppState } - 'drafts.set': { params: { key: string } & Draft; result: null } + /** Saves a draft, and answers the revision it was saved as. */ + 'drafts.set': { params: { key: string } & Draft; result: { rev: number } } /** Keeps a pasted picture in a file of its own, outside any project, and answers its path, for a draft to keep. */ 'drafts.picture': { params: { name: string; data: string }; result: string } /** What the agent needs before a send: the chosen model's sign-in, uv, and a Python uv can run. Empty when ready. */ @@ -123,7 +130,11 @@ export interface Methods { 'auth.logout': { params: { provider: ProviderId }; result: Login[] } 'chat.open': { params: { project: string }; result: ChatView } /** `requestId` is the window's own id for this message: sending it again never sends twice. */ - 'chat.send': { params: { project: string; requestId: string; text: string; attachments: string[] }; result: null } + /** + * Sends a message. `draftRev` names the saved draft it went out with, which the send removes in the same commit. A + * draft saved after it, newer, stays. + */ + 'chat.send': { params: { project: string; requestId: string; text: string; attachments: string[]; draftRev?: number }; result: null } 'chat.stop': { params: { project: string }; result: null } 'chat.retry': { params: { project: string }; result: null } /** The tools a project's agent is offered, as Pi resolves them. */ diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 98e97528..b3c8323b 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -13,7 +13,7 @@ import { type Conversation, defineDoc, type HarnessOptions, Harness, type Regist import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node' import type { LoginIndex } from './inference' import type { LegacyProject } from './legacy' -import type { Draft, LoginKind, Project, Settings, Setup, StoredState, Turn, Waiting } from './protocol' +import type { Draft, LoginKind, Project, SavedDraft, Settings, Setup, StoredState, Turn, Waiting } from './protocol' const context = BACKGROUND_CONTEXT @@ -57,7 +57,7 @@ const ProjectsDoc = defineDoc<{ projects: Project[] }>({ checkpointWhen: always, }) -export const DraftsDoc = defineDoc<{ drafts: Record }>({ +export const DraftsDoc = defineDoc<{ drafts: Record; seq?: number }>({ kind: 'm3d.drafts', version: 1, scope: 'conversation', @@ -211,11 +211,15 @@ export class Store implements LoginIndex { }, context) } - async setDraft(key: string, draft: Draft): Promise { - await this.harness.commit(async (tx) => { - const { drafts } = await tx.doc(DraftsDoc, this.root.id) - if (draft.text === '' && draft.attachments.length === 0) delete drafts[key] - else drafts[key] = draft + /** Saves a draft, or removes an empty one, and answers its revision. A store from before revisions starts at 1. */ + async setDraft(key: string, draft: Draft): Promise { + return this.harness.commit(async (tx) => { + const doc = await tx.doc(DraftsDoc, this.root.id) + const rev = (doc.seq ?? 0) + 1 + doc.seq = rev + if (draft.text === '' && draft.attachments.length === 0) delete doc.drafts[key] + else doc.drafts[key] = { ...draft, rev } + return rev }, context) } diff --git a/sidecar/test/agent.test.ts b/sidecar/test/agent.test.ts index 56177567..a3392c21 100644 --- a/sidecar/test/agent.test.ts +++ b/sidecar/test/agent.test.ts @@ -45,7 +45,10 @@ async function waitForFile(path: string, ms = 15_000) { } } -const send = (app: App, project: string, requestId: string, text: string) => app.call('chat.send', { project, requestId, text, attachments: [] }) +const send = (app: App, project: string, requestId: string, text: string, draftRev?: number) => + app.call('chat.send', { project, requestId, text, attachments: [], ...(draftRev === undefined ? {} : { draftRev }) }) +const saveDraft = async (app: App, key: string, text: string) => + ((await app.call('drafts.set', { key, text, attachments: [] })) as { rev: number }).rev describe('a send', () => { test('commits the message, the settings its turn runs with, and the draft removal together, and only once', async () => { @@ -53,9 +56,9 @@ describe('a send', () => { const app = start(where, 'models') await app.next() const project = await newProject(app) - await app.call('drafts.set', { key: project, text: 'A 20 mm cube', attachments: [] }) + const rev = await saveDraft(app, project, 'A 20 mm cube') - await send(app, project, 'r1', 'A 20 mm cube') + await send(app, project, 'r1', 'A 20 mm cube', rev) expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({}) const view = (await app.call('chat.open', { project })) as ChatView expect(view.entries[0]).toMatchObject({ kind: 'user', text: 'A 20 mm cube' }) @@ -75,9 +78,9 @@ describe('a send', () => { // A send while a turn runs is refused whole: no message, and the draft stays. await send(app, project, 'r2', 'please wait') await waitForFile(join(where.projects, project, 'started')) - await app.call('drafts.set', { key: project, text: 'make it red', attachments: [] }) - await expect(send(app, project, 'r3', 'make it red')).rejects.toThrow('The agent is still working on the last message.') - expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: [] }) + const red = await saveDraft(app, project, 'make it red') + await expect(send(app, project, 'r3', 'make it red', red)).rejects.toThrow('The agent is still working on the last message.') + expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: [], rev: red }) const refused = (await app.call('chat.open', { project })) as ChatView expect(refused.entries.filter((entry) => entry.kind === 'user').map((entry) => entry.kind === 'user' && entry.text)).toEqual(['A 20 mm cube', 'please wait']) writeFileSync(join(where.projects, project, 'go'), '') @@ -85,14 +88,16 @@ describe('a send', () => { await app.quit() }) - test('leaves a draft that holds newer text than it sends', async () => { + test('leaves a draft saved after the one it went out with, even one that reads the same', async () => { const where = scratch() const app = start(where, 'models') await app.next() const project = await newProject(app) - await app.call('drafts.set', { key: project, text: 'make it blue', attachments: [] }) - await send(app, project, 'r1', 'make it red') - expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it blue', attachments: [] }) + const sent = await saveDraft(app, project, 'make it red') + const newer = ((await app.call('drafts.set', { key: project, text: 'make it red', attachments: ['/me/new.jpg'] })) as { rev: number }).rev + expect(newer).toBeGreaterThan(sent) + await send(app, project, 'r1', 'make it red', sent) + expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: ['/me/new.jpg'], rev: newer }) await app.quit() }) }) diff --git a/sidecar/test/store.test.ts b/sidecar/test/store.test.ts index e774e091..dd75ff8e 100644 --- a/sidecar/test/store.test.ts +++ b/sidecar/test/store.test.ts @@ -131,7 +131,7 @@ describe('app state', () => { await app.next() await app.call('drafts.set', { key: 'new', text: 'A shelf bracket', attachments: [] }) await app.call('projects.create', { title: 'A knob' }) - expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ new: { text: 'A shelf bracket', attachments: [] } }) + expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ new: { text: 'A shelf bracket', attachments: [], rev: expect.any(Number) } }) await app.quit() }) @@ -141,7 +141,7 @@ describe('app state', () => { await app.next() await app.call('drafts.set', { key: 'new', text: 'A knob', attachments: ['refs/shaft.png'] }) const project = (await app.call('projects.create', { title: 'A knob' })) as { id: string } - expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ [project.id]: { text: 'A knob', attachments: ['refs/shaft.png'] } }) + expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ [project.id]: { text: 'A knob', attachments: ['refs/shaft.png'], rev: expect.any(Number) } }) await app.quit() }) }) diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index a49a7d8d..62167e07 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -192,7 +192,7 @@ describe('the update gate', () => { await expect(app.call('chat.send', { project, requestId: 'r2', text: 'make it red', attachments: [] })).rejects.toThrow('An update is installing.') const view = (await app.call('chat.open', { project })) as ChatView expect(view.entries.filter((entry) => entry.kind === 'user')).toHaveLength(1) - expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: [] }) + expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toMatchObject({ text: 'make it red', attachments: [] }) await app.quit() }) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 3382bf69..ff34aa48 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -54,6 +54,9 @@ const type = (text: string) => fireEvent.change(prompt(), { target: { value: tex type Calls = Record) => Promise> /** The sidecar as the window sees it: one `sidecar` command, by method. */ +/** The revisions the mocked sidecar gives saved drafts, growing as the real one's do. */ +let draftRevs = 0 + function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Record = {}) { invokeMock.mockImplementation((command: string, args: { method: string; params: Record }) => { if (command === 'update_check') return Promise.resolve({ status: 'upToDate' }) @@ -71,6 +74,8 @@ function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Recor return Promise.resolve(view(String(args.params.project))) case 'setup.check': return Promise.resolve([]) + case 'drafts.set': + return Promise.resolve({ rev: ++draftRevs }) default: return Promise.resolve(null) } @@ -451,6 +456,79 @@ describe('StudioApp', () => { expect(screen.queryByTestId('attachments')).toBeNull() }) + it('names the saved draft a send went out with, so the sidecar removes that one and no newer one', async () => { + mockSidecar() + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + const saved = invokeMock.mock.results.filter((_, i) => invokeMock.mock.calls[i]?.[1]?.method === 'drafts.set') + const rev = ((await saved[saved.length - 1]?.value) as { rev: number }).rev + expect(sidecarCalls('chat.send')[0]?.draftRev).toBe(rev) + }) + + it("leaves a newer start screen draft with the same picture alone when a first send goes through", async () => { + let finishSend: (value: unknown) => void = () => {} + mockSidecar({ + 'projects.create': () => Promise.resolve({ id: '2026-11-06-a-clip', conversation: 4, title: 'A clip', createdMs: 3 }), + 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), + studio_import_file: () => Promise.resolve('refs/photo.jpg'), + }) + render() + await screen.findByText('Project B') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + type('A clip') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + fireEvent.click(screen.getByTestId('new-project')) + type('A hook') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + await new Promise((resolve) => setTimeout(resolve, 500)) + await act(async () => finishSend(null)) + expect(prompt().value).toBe('A hook') + expect(screen.getByTestId('attachments').textContent).toContain('photo.jpg') + const saves = sidecarCalls('drafts.set').filter((save) => save.key === 'new') + expect(saves[saves.length - 1]).toEqual({ key: 'new', text: 'A hook', attachments: ['/Users/me/photo.jpg'] }) + }) + + it('keeps the composer and the saved draft the same when a picture was added during a send', async () => { + let finishSend: (value: unknown) => void = () => {} + mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)) }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/new.jpg'] } }))) + await new Promise((resolve) => setTimeout(resolve, 500)) + await act(async () => finishSend(null)) + const shown = { text: prompt().value, pictures: screen.getByTestId('attachments').textContent } + fireEvent.click(screen.getByText('Project A')) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect({ text: prompt().value, pictures: screen.getByTestId('attachments').textContent }).toEqual(shown) + }) + + it('clears a picture-only first message from the start screen the person went back to', async () => { + let finishSend: (value: unknown) => void = () => {} + mockSidecar({ + 'projects.create': () => Promise.resolve({ id: '2026-11-06-new-part', conversation: 4, title: 'New part', createdMs: 3 }), + 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), + studio_import_file: () => Promise.resolve('refs/photo.jpg'), + }) + render() + await screen.findByText('Project B') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + fireEvent.click(screen.getByTestId('new-project')) + await act(async () => finishSend(null)) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + it('keeps chat changes that arrive while the chat opens', async () => { let finishOpen: (view: unknown) => void = () => {} mockSidecar({ 'chat.open': () => new Promise((resolve) => (finishOpen = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 0a6453ea..dd70df1f 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -97,8 +97,13 @@ export default function StudioApp() { const composerText = useRef('') /** The project whose chat view is loaded. Chat changes for the open project wait in `early` until it is. */ const loaded = useRef(null) - /** Counts the person's edits to the composer, so a send clears only the text it sent, not a later edit that reads the same. */ - const edits = useRef(0) + /** + * The person's edits to each screen's composer, text or pictures, so a send clears a screen only when nothing changed + * there since it went out. + */ + const edits = useRef>({}) + /** The revision of each screen's saved draft, so a send names the draft it went out with. */ + const revs = useRef>({}) const early = useRef([]) /** Counts chat loads, so only the newest load of the open chat replaces its view, not an older one that answers late. */ const loads = useRef(0) @@ -120,6 +125,7 @@ export default function StudioApp() { const local = drafts.current[START] const restoreStart = !startPicturesTouched.current && saved !== undefined && saved.attachments.length > 0 && (local?.attachments.length ?? 0) === 0 drafts.current = { ...state.drafts, ...drafts.current } + for (const [key, draft] of Object.entries(state.drafts)) revs.current[key] ??= draft.rev if (restoreStart) drafts.current = { ...drafts.current, [START]: { text: local?.text ?? saved.text, attachments: saved.attachments } } // A project made while this was on its way is newer than the saved list, so it stays. setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) @@ -225,7 +231,8 @@ export default function StudioApp() { window.clearTimeout(waiting.timer) unsaved.current = null drafts.current = { ...drafts.current, [waiting.key]: waiting.draft } - await sidecar('drafts.set', { key: waiting.key, ...waiting.draft }) + const { rev } = await sidecar('drafts.set', { key: waiting.key, ...waiting.draft }) + revs.current[waiting.key] = Math.max(revs.current[waiting.key] ?? 0, rev) } // The composer's pictures as they are now, for a draft saved from outside a render, and to free their previews. @@ -240,8 +247,14 @@ export default function StudioApp() { unsaved.current = { key: currentId.current ?? START, draft: { text, attachments: pictures.map((a) => a.path) }, timer } } + /** Counts an edit to the open screen's composer. */ + const edited = () => { + const key = currentId.current ?? START + edits.current[key] = (edits.current[key] ?? 0) + 1 + } + const type = (value: string) => { - edits.current += 1 + edited() setText(value) queueDraft(value, unsent.current) if (currentId.current === null) typedOnStart.current = true @@ -251,6 +264,7 @@ export default function StudioApp() { /** Changes the composer's pictures, and saves them with the draft. */ const setPictures = (next: Attachment[]) => { if (currentId.current === null) startPicturesTouched.current = true + edited() unsent.current = next setAttachments(next) queueDraft(composerText.current, next) @@ -373,31 +387,39 @@ export default function StudioApp() { let sentTo: string | null = null try { await saveDraft() - const sentEdits = edits.current - // The saved draft this send went out with: the open project's, or the start screen's, which may move to the new - // project. A save after it is a newer draft, which the send leaves alone. - const sentDraft = drafts.current[current?.id ?? START] + // What this send went out with: its screen's edits and saved draft revision. The start screen's draft may move to + // the new project, by the sidecar's rule that its text is the title. + const fromKey = current?.id ?? START + const fromEdits = edits.current[fromKey] ?? 0 + const fromRev = revs.current[fromKey] const createdHere = current === null + let moved = false let project = current if (!project) { const title = message || 'New part' const created = await sidecar('projects.create', { title }) project = created - // The sidecar moved the start screen's draft to the new project in the same commit when the draft's text is the - // project's title, as for a first message with text. One of only pictures stays on the start screen. const { [START]: started, ...rest } = drafts.current - if (started !== undefined && started.text.trim() === title) drafts.current = { ...rest, [created.id]: started } + moved = started !== undefined && started.text.trim() === title + if (moved && started !== undefined) { + drafts.current = { ...rest, [created.id]: started } + if (fromRev !== undefined) revs.current[created.id] = fromRev + } setProjects((prev) => [created, ...prev]) if (loadRequest.current === request) { // The person stayed on the screen this was sent from, so text typed while the project was made follows them - // to it, and the start screen is empty again. A save still waiting belongs to that screen, so it goes too. + // to it as its draft, and the start screen is empty again. A save still waiting belongs to that screen, so it + // goes too. const typed = composerText.current if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) unsaved.current = null if (typed.trim() !== message) { + edits.current[created.id] = (edits.current[created.id] ?? 0) + 1 drafts.current = { ...drafts.current, [created.id]: { text: typed, attachments: [] }, [START]: { text: '', attachments: [] } } for (const [key, value] of [[created.id, typed], [START, '']]) { - void sidecar('drafts.set', { key, text: value, attachments: [] }).catch((err: unknown) => setError(String(err))) + void sidecar('drafts.set', { key, text: value, attachments: [] }) + .then(({ rev }) => (revs.current[key] = Math.max(revs.current[key] ?? 0, rev))) + .catch((err: unknown) => setError(String(err))) } } show(created, true) @@ -407,6 +429,10 @@ export default function StudioApp() { } const id = project.id sentTo = id + // Edits on the project's screen after this send went out make a newer draft. A project this send made had none + // before it, so text typed while it was made counts as one. + const projectEdits = createdHere ? 0 : fromEdits + const draftRev = createdHere ? (moved ? fromRev : undefined) : fromRev const outgoing = attachments const last = pending.current.get(id) const again = @@ -423,38 +449,58 @@ export default function StudioApp() { id: crypto.randomUUID(), } pending.current.set(id, sendable) - await sidecar('chat.send', { project: id, requestId: sendable.id, text: message, attachments: sendable.images }) + await sidecar('chat.send', { + project: id, + requestId: sendable.id, + text: message, + attachments: sendable.images, + ...(draftRev === undefined ? {} : { draftRev }), + }) pending.current.delete(id) setSendErrors(({ [id]: _, ...rest }) => rest) - // Text typed while this was sending is a new message, even one that reads the same, so the sent text is cleared - // only when nothing was typed since, and only in the view that sent it. - // The sent project's saved draft goes, unless a newer draft for it was saved since, or waits to be. - if (sentDraft !== undefined && drafts.current[id] === sentDraft && unsaved.current?.key !== id) { - drafts.current = { ...drafts.current, [id]: { text: '', attachments: [] } } - } - if (edits.current === sentEdits && loadRequest.current === request) setText((now) => (now.trim() === message ? '' : now)) - // The sent pictures leave every draft that names them: the one waiting to be saved, the saved one of this - // project, and the start screen's, which a first message of only pictures leaves in place. A picture added - // while these imported waits for the next message. const sent = new Set(outgoing.map((a) => a.path)) - const unsentNames = (draft: Draft) => draft.attachments.filter((path) => !sent.has(path)) - // Only this send's drafts: its project's, and the start screen's when this send made the project from it. - const keys = createdHere ? [id, START] : [id] - if (unsaved.current !== null && keys.includes(unsaved.current.key)) { - unsaved.current.draft = { ...unsaved.current.draft, attachments: unsentNames(unsaved.current.draft) } - } - for (const key of keys) { - const draft = drafts.current[key] - if (draft === undefined || draft.attachments.every((path) => !sent.has(path))) continue - drafts.current = { ...drafts.current, [key]: { ...draft, attachments: unsentNames(draft) } } - if (unsaved.current?.key !== key) { - void sidecar('drafts.set', { key, ...drafts.current[key] }).catch((err: unknown) => setError(String(err))) + const empty: Draft = { text: '', attachments: [] } + if ((edits.current[id] ?? 0) === projectEdits) { + // Nothing changed on the project's screen since: its composer held what went out, and the sidecar removed the + // draft it went out with. + drafts.current = { ...drafts.current, [id]: empty } + if (currentId.current === id) { + setText('') + unsent.current = [] + setAttachments([]) + } + } else { + // A newer draft, even one that reads the same, is the next message. Only the pictures just sent leave it. + const keep = (draft: Draft): Draft => ({ ...draft, attachments: draft.attachments.filter((path) => !sent.has(path)) }) + if (unsaved.current?.key === id) unsaved.current.draft = keep(unsaved.current.draft) + const cached = drafts.current[id] + if (cached !== undefined && cached.attachments.some((path) => sent.has(path))) { + drafts.current = { ...drafts.current, [id]: keep(cached) } + if (unsaved.current?.key !== id) { + void sidecar('drafts.set', { key: id, ...keep(cached) }) + .then(({ rev }) => (revs.current[id] = Math.max(revs.current[id] ?? 0, rev))) + .catch((err: unknown) => setError(String(err))) + } + } + if (currentId.current === id) { + unsent.current = unsent.current.filter((a) => !sent.has(a.path)) + setAttachments(unsent.current) } } - // The composer of the sent project, if it is open now, even after a visit elsewhere brought its draft back. - if (currentId.current === id) { - unsent.current = unsent.current.filter((a) => !sent.has(a.path)) - setAttachments(unsent.current) + // A first message the start screen still holds, as one of only pictures, which the sidecar does not move, goes + // from it too, unless the person started something new there. + if (createdHere && !moved && (edits.current[START] ?? 0) === fromEdits) { + drafts.current = { ...drafts.current, [START]: empty } + if (unsaved.current?.key !== START) { + void sidecar('drafts.set', { key: START, ...empty }) + .then(({ rev }) => (revs.current[START] = Math.max(revs.current[START] ?? 0, rev))) + .catch((err: unknown) => setError(String(err))) + } + if (currentId.current === null) { + setText('') + unsent.current = [] + setAttachments([]) + } } outgoing.forEach(releaseAttachment) } catch (err) { From 1ab5809ba4d1e5970f84a5b0833b1b80fc50f0d4 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 15:38:52 -0600 Subject: [PATCH 43/59] chore(formal): record chat.ts's hash after draft revisions The send's draft removal does not touch the update gate. TLC gives every recorded verdict. Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 112c6a28..28e56019 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts -860fcae9bcc1f04f6a3de1352f13b2923c011bc8dbb91f52de5d8cc493172f30 sidecar/src/chat.ts +fccd0d9fb5629626d0ada99746dfb3805381238eadfeedb7c6f375a491123828 sidecar/src/chat.ts effb5c139b839c110f95d3f996b40cee7fbe4f1894a86fb2fb1b8d7d6a70c6d8 sidecar/src/app.ts From 5f3b790fb70a1dcfca3d20b87c98e06edec9c21b Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 15:51:17 -0600 Subject: [PATCH 44/59] fix(studio): a send names the draft save it went out with, and edits made meanwhile stay - The send takes its screen's edit count and the revision of its last draft save before it waits, so text typed during that save is a newer draft and stays. - Any change on the start screen while the project is made, a picture or text typed back to the same, follows the person to the project as a newer draft. - Drafts saved before drafts had revisions get one when the sidecar starts, so their send takes them away. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/app.ts | 1 + sidecar/src/store.ts | 14 ++++++++- sidecar/test/store.test.ts | 30 ++++++++++++++++++- src/studio/StudioApp.test.tsx | 35 ++++++++++++++++++++++ src/studio/StudioApp.tsx | 55 ++++++++++++++++++++--------------- 5 files changed, 110 insertions(+), 25 deletions(-) diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index af21b7df..4be01019 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -100,6 +100,7 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis return { imported: imported.length, blocked } } let oldChats = await importOld() + await store.reviseOldDrafts() // A pasted picture is kept until it is sent, which copies it into its project, or its draft lets it go. A window that // outlived a crash may still hold one it has not saved in a draft yet, so only pictures older than a day that no // draft names are removed. diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index b3c8323b..9b62fad6 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -57,7 +57,7 @@ const ProjectsDoc = defineDoc<{ projects: Project[] }>({ checkpointWhen: always, }) -export const DraftsDoc = defineDoc<{ drafts: Record; seq?: number }>({ +export const DraftsDoc = defineDoc<{ drafts: Record; seq?: number }>({ kind: 'm3d.drafts', version: 1, scope: 'conversation', @@ -223,6 +223,18 @@ export class Store implements LoginIndex { }, context) } + /** Gives each draft saved before drafts had revisions one, so a send can take it away as it takes any other. */ + async reviseOldDrafts(): Promise { + await this.harness.commit(async (tx) => { + const doc = await tx.doc(DraftsDoc, this.root.id) + for (const draft of Object.values(doc.drafts)) { + if (draft.rev !== undefined) continue + doc.seq = (doc.seq ?? 0) + 1 + draft.rev = doc.seq + } + }, context) + } + /** * Makes a project: its conversation, its place in the index, and the start screen's draft moved to it when that * draft holds the project's first message, in one commit. A start screen draft with other text is the person's next diff --git a/sidecar/test/store.test.ts b/sidecar/test/store.test.ts index dd75ff8e..7017c4f1 100644 --- a/sidecar/test/store.test.ts +++ b/sidecar/test/store.test.ts @@ -3,9 +3,11 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { tmpdir } from 'node:os' import { join } from 'node:path' import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' +import { createModels, InMemoryCredentialStore } from '@earendil-works/pi-ai' +import { createRegistry } from '@earendil-works/pi-durable' import { registerStorageConformance } from '@earendil-works/pi-durable/testing' import { EXIT } from '../src/protocol' -import { openStorage } from '../src/store' +import { DraftsDoc, openStorage, Store } from '../src/store' import { spawnSidecar } from './sidecar' function scratch() { @@ -135,6 +137,32 @@ describe('app state', () => { await app.quit() }) + test('a draft saved before drafts had revisions gets one at start, so its send takes it away', async () => { + const { data, projects } = scratch() + const old = await Store.open({ + dataDir: data, + projectsDir: projects, + models: createModels({ credentials: new InMemoryCredentialStore() }), + registry: createRegistry(), + env: () => { + throw new Error('no conversation runs here') + }, + }) + await old.harness.commit(async (tx) => { + ;(await tx.doc(DraftsDoc, old.root.id)).drafts.new = { text: 'A knob', attachments: [] } + }, BACKGROUND_CONTEXT) + await old.close() + + const app = spawnSidecar(data, projects) + await app.next() + const { drafts } = (await app.call('state.get')) as { drafts: Record } + expect(drafts.new).toEqual({ text: 'A knob', attachments: [], rev: expect.any(Number) }) + const project = (await app.call('projects.create', { title: 'A knob' })) as { id: string } + await app.call('chat.send', { project: project.id, requestId: 'r1', text: 'A knob', attachments: [], draftRev: drafts.new?.rev }) + expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({}) + await app.quit() + }) + test('a project made from the start screen takes the start screen draft with it', async () => { const { data, projects } = scratch() const app = spawnSidecar(data, projects) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index ff34aa48..e0414ffd 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -529,6 +529,41 @@ describe('StudioApp', () => { expect(screen.queryByTestId('attachments')).toBeNull() }) + it('keeps text typed while the draft before a send was still being saved', async () => { + let finishSave: (value: unknown) => void = () => {} + let saves = 0 + mockSidecar({ 'drafts.set': () => (++saves === 1 ? new Promise((resolve) => (finishSave = resolve)) : Promise.resolve({ rev: 100 + saves })) }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(1)) + type('Make it blue') + // The newer text's own save ends first, so only the send's first save names what went out. + await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(2)) + await act(async () => finishSave({ rev: 1 })) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + expect(sidecarCalls('chat.send')[0]).toMatchObject({ text: 'Make it red', draftRev: 1 }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(prompt().value).toBe('Make it blue') + }) + + it('keeps a picture added while the project of a first message was being made', async () => { + let finishCreate: (project: unknown) => void = () => {} + mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)), studio_import_file: () => Promise.resolve('refs/photo.jpg') }) + render() + await screen.findByText('Project B') + type('A clip') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/later.jpg'] } }))) + await act(async () => finishCreate({ id: '2026-11-06-a-clip', conversation: 4, title: 'A clip', createdMs: 3 })) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(screen.getByTestId('attachments').textContent).toContain('later.jpg') + }) + it('keeps chat changes that arrive while the chat opens', async () => { let finishOpen: (view: unknown) => void = () => {} mockSidecar({ 'chat.open': () => new Promise((resolve) => (finishOpen = resolve)) }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index dd70df1f..a85fb765 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -104,6 +104,8 @@ export default function StudioApp() { const edits = useRef>({}) /** The revision of each screen's saved draft, so a send names the draft it went out with. */ const revs = useRef>({}) + /** The last save the window started for each screen's draft, and the revision it got, or none if it failed. */ + const saving = useRef>>({}) const early = useRef([]) /** Counts chat loads, so only the newest load of the open chat replaces its view, not an older one that answers late. */ const loads = useRef(0) @@ -224,6 +226,16 @@ export default function StudioApp() { } }, []) + /** Saves one screen's draft, and keeps the revision the sidecar gave it. */ + const storeDraft = (key: string, draft: Draft) => { + const save = sidecar('drafts.set', { key, ...draft }).then(({ rev }) => { + revs.current[key] = Math.max(revs.current[key] ?? 0, rev) + return rev + }) + saving.current[key] = save.catch(() => undefined) + return save + } + /** Saves the draft now, if one waits to be saved. */ const saveDraft = async () => { const waiting = unsaved.current @@ -231,8 +243,7 @@ export default function StudioApp() { window.clearTimeout(waiting.timer) unsaved.current = null drafts.current = { ...drafts.current, [waiting.key]: waiting.draft } - const { rev } = await sidecar('drafts.set', { key: waiting.key, ...waiting.draft }) - revs.current[waiting.key] = Math.max(revs.current[waiting.key] ?? 0, rev) + await storeDraft(waiting.key, waiting.draft) } // The composer's pictures as they are now, for a draft saved from outside a render, and to free their previews. @@ -385,13 +396,18 @@ export default function StudioApp() { // while it runs changes `loadRequest`, and then the send leaves that view alone. let request = loadRequest.current let sentTo: string | null = null + const empty: Draft = { text: '', attachments: [] } try { - await saveDraft() - // What this send went out with: its screen's edits and saved draft revision. The start screen's draft may move to - // the new project, by the sidecar's rule that its text is the title. + // What this send went out with: its screen's edits and the revision of the last save of its draft, both taken + // before anything waits, as the person may go on typing. The start screen's draft may move to the new project, by + // the sidecar's rule that its text is the title. const fromKey = current?.id ?? START const fromEdits = edits.current[fromKey] ?? 0 - const fromRev = revs.current[fromKey] + const before = revs.current[fromKey] + const saved = saveDraft() + const fromSave = saving.current[fromKey] + await saved + const fromRev = (await fromSave) ?? before const createdHere = current === null let moved = false let project = current @@ -407,19 +423,17 @@ export default function StudioApp() { } setProjects((prev) => [created, ...prev]) if (loadRequest.current === request) { - // The person stayed on the screen this was sent from, so text typed while the project was made follows them - // to it as its draft, and the start screen is empty again. A save still waiting belongs to that screen, so it - // goes too. - const typed = composerText.current + // The person stayed on the screen this was sent from, so what they changed there while the project was made, + // even back to the same text, is a newer draft. It follows them to the project, and the start screen is empty + // again. A save still waiting belongs to that screen, so it goes too. if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) unsaved.current = null - if (typed.trim() !== message) { + if ((edits.current[START] ?? 0) !== fromEdits) { + const draft: Draft = { text: composerText.current, attachments: unsent.current.map((a) => a.path) } edits.current[created.id] = (edits.current[created.id] ?? 0) + 1 - drafts.current = { ...drafts.current, [created.id]: { text: typed, attachments: [] }, [START]: { text: '', attachments: [] } } - for (const [key, value] of [[created.id, typed], [START, '']]) { - void sidecar('drafts.set', { key, text: value, attachments: [] }) - .then(({ rev }) => (revs.current[key] = Math.max(revs.current[key] ?? 0, rev))) - .catch((err: unknown) => setError(String(err))) + drafts.current = { ...drafts.current, [created.id]: draft, [START]: empty } + for (const [key, value] of [[created.id, draft], [START, empty]] as const) { + void storeDraft(key, value).catch((err: unknown) => setError(String(err))) } } show(created, true) @@ -459,7 +473,6 @@ export default function StudioApp() { pending.current.delete(id) setSendErrors(({ [id]: _, ...rest }) => rest) const sent = new Set(outgoing.map((a) => a.path)) - const empty: Draft = { text: '', attachments: [] } if ((edits.current[id] ?? 0) === projectEdits) { // Nothing changed on the project's screen since: its composer held what went out, and the sidecar removed the // draft it went out with. @@ -477,9 +490,7 @@ export default function StudioApp() { if (cached !== undefined && cached.attachments.some((path) => sent.has(path))) { drafts.current = { ...drafts.current, [id]: keep(cached) } if (unsaved.current?.key !== id) { - void sidecar('drafts.set', { key: id, ...keep(cached) }) - .then(({ rev }) => (revs.current[id] = Math.max(revs.current[id] ?? 0, rev))) - .catch((err: unknown) => setError(String(err))) + void storeDraft(id, keep(cached)).catch((err: unknown) => setError(String(err))) } } if (currentId.current === id) { @@ -492,9 +503,7 @@ export default function StudioApp() { if (createdHere && !moved && (edits.current[START] ?? 0) === fromEdits) { drafts.current = { ...drafts.current, [START]: empty } if (unsaved.current?.key !== START) { - void sidecar('drafts.set', { key: START, ...empty }) - .then(({ rev }) => (revs.current[START] = Math.max(revs.current[START] ?? 0, rev))) - .catch((err: unknown) => setError(String(err))) + void storeDraft(START, empty).catch((err: unknown) => setError(String(err))) } if (currentId.current === null) { setText('') From 47918da1f5919db91199104294c1e05157a87b07 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 15:51:41 -0600 Subject: [PATCH 45/59] chore(formal): record app.ts's hash after the old-draft revisions Co-Authored-By: Claude Opus 5.5 --- .../skills/formal-verify-materialize-3d/models/sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 28e56019..0b33d48c 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -7,4 +7,4 @@ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/ b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts fccd0d9fb5629626d0ada99746dfb3805381238eadfeedb7c6f375a491123828 sidecar/src/chat.ts -effb5c139b839c110f95d3f996b40cee7fbe4f1894a86fb2fb1b8d7d6a70c6d8 sidecar/src/app.ts +389fba635222a03e2284be87fe3eb4676feafda7754aba26b56372f46c47503d sidecar/src/app.ts From 0aa8402d90f00018b90257ef87d474face48ea01 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 16:01:35 -0600 Subject: [PATCH 46/59] test(sidecar): start the test sidecar with the bun running the tests The readiness tests give the sidecar a PATH of only /usr/bin and /bin. On the macOS runner bun is not there, so spawning "bun" failed before the test ran. Co-Authored-By: Claude Opus 5.5 --- sidecar/test/sidecar.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sidecar/test/sidecar.ts b/sidecar/test/sidecar.ts index 928bfe8b..4fe8d04b 100644 --- a/sidecar/test/sidecar.ts +++ b/sidecar/test/sidecar.ts @@ -14,7 +14,8 @@ export function spawnSidecar( options: { script?: string; env?: Record; version?: string; hold?: (request: Message, answer: () => void) => void } = {}, ) { const entry = options.script === undefined ? 'src/main.ts' : 'test/fixtures/scripted-sidecar.ts' - const child = Bun.spawn(['bun', entry, '--app-version', options.version ?? '9.9.9', '--data', dataDir, '--projects', projectsDir], { + // The bun running the tests, as a test may give the sidecar a PATH without one. + const child = Bun.spawn([process.execPath, entry, '--app-version', options.version ?? '9.9.9', '--data', dataDir, '--projects', projectsDir], { cwd: `${import.meta.dir}/..`, env: { ...process.env, ...options.env, ...(options.script === undefined ? {} : { M3D_SCRIPT: options.script }) }, stdin: 'pipe', From 27f2c040296a454dfc5f818d2804e4d2db5afd00 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 16:12:25 -0600 Subject: [PATCH 47/59] fix(studio): a retry matches by picture path, a send always names its draft, a quit waits for saves, and uv in ~/.local/bin is found From Pullfrog's review of 47918da, each with a test that failed first. - An unsent message is the same message when its text and picture paths match, so pictures brought back from its saved draft keep its request id, and the sidecar sends it once. - A send names the draft revision it went out with in every case. The sidecar removes the project's draft only when that revision is still there, so a first send whose start draft moved is consumed even if the window's cache changed, and a newer draft stays. - A quit waits for every draft save already on its way, not only the one still waiting for the typing pause. - The agent's commands, and the check before a send, also look in ~/.local/bin, where uv's installer puts uv, so "Check again" finds a uv installed while the app runs. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 11 +++++- sidecar/test/tools.test.ts | 14 ++++++-- src/studio/StudioApp.test.tsx | 66 +++++++++++++++++++++++++++++++++++ src/studio/StudioApp.tsx | 25 +++++++------ 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index 3a5d2a71..fd551cf4 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -42,13 +42,22 @@ const OUTPUT_TAIL = 20_000 /** The only variables a command the agent runs gets. */ const COMMAND_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR'] as const -/** The environment a command the agent runs gets: the sidecar's own values of `COMMAND_ENV`, and nothing else. */ +/** + * The environment a command the agent runs gets: the sidecar's own values of `COMMAND_ENV`, and nothing else, but for + * ~/.local/bin at the end of PATH. uv's installer puts uv there and adds it to the shell's startup files, which the + * PATH the app read at its start comes from, so a uv installed while the app runs is found without a restart. + */ function commandEnv(): Record { const vars: Record = {} for (const name of COMMAND_ENV) { const value = process.env[name] if (value !== undefined) vars[name] = value } + if (vars.HOME !== undefined) { + const local = join(vars.HOME, '.local', 'bin') + const path = vars.PATH === undefined ? [] : vars.PATH.split(':') + if (!path.includes(local)) vars.PATH = [...path, local].join(':') + } return vars } diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index 50955513..2850539d 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { logFollower, once, operationId, tailOf } from '../src/agent' @@ -133,9 +133,10 @@ describe('the check before a send', () => { chmodSync(join(dir, 'uv'), 0o755) return `${dir}:/usr/bin:/bin` } - const check = async (path: string, key: boolean) => { + // A home of its own, so a uv the machine has in ~/.local/bin is not found. + const check = async (path: string, key: boolean, home = mkdtempSync(join(tmpdir(), 'm3d-home-'))) => { const dir = mkdtempSync(join(tmpdir(), 'm3d-check-')) - const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { env: { PATH: path } }) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { env: { PATH: path, HOME: home } }) await app.next() if (key) await app.call('auth.setKey', { provider: 'anthropic', key: 'token' }) const needs = (await app.call('setup.check', {})) as { kind: string; fix: string }[] @@ -149,6 +150,13 @@ describe('the check before a send', () => { expect(needs.every((need) => need.fix.length > 0)).toBe(true) }) + test("finds a uv its installer put in ~/.local/bin, which the app's PATH from before the install lacks", async () => { + const home = mkdtempSync(join(tmpdir(), 'm3d-home-')) + mkdirSync(join(home, '.local')) + renameSync(toolchain(true).split(':')[0] ?? '', join(home, '.local', 'bin')) + expect(await check('/usr/bin:/bin', true, home)).toEqual([]) + }) + test('names a missing Python once uv is there', async () => { expect((await check(toolchain(false), true)).map((need) => need.kind)).toEqual(['python']) }) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index e0414ffd..3c9e1b93 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -529,6 +529,72 @@ describe('StudioApp', () => { expect(screen.queryByTestId('attachments')).toBeNull() }) + it('sends a saved picture again under the same request id after its answer was lost and the project was left and opened again', async () => { + let sends = 0 + mockSidecar( + { + 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), + studio_import_file: () => Promise.resolve('refs/pic.jpg'), + }, + [projectA, projectB], + { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'] } }, + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip like this')) + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip like this')) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + const [first, second] = sidecarCalls('chat.send') + expect(second?.requestId).toBe(first?.requestId) + }) + + it('names the start draft a first send went out with, even when the person saved a newer one and left before the project was made', async () => { + let finishCreate: (project: unknown) => void = () => {} + const revs: number[] = [] + mockSidecar({ + 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)), + 'drafts.set': () => { + revs.push(++draftRevs) + return Promise.resolve({ rev: draftRevs }) + }, + }) + render() + await screen.findByText('Project B') + type('A knob') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) + const sent = revs[0] + type('Then a dial') + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(2)) + await act(async () => finishCreate({ id: '2026-11-06-a-knob', conversation: 4, title: 'A knob', createdMs: 3 })) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + expect(sidecarCalls('chat.send')[0]).toMatchObject({ text: 'A knob', draftRev: sent }) + }) + + it('waits for a draft save already on its way before it says a quit may go on', async () => { + let finishSave: (value: unknown) => void = () => {} + mockSidecar({ 'drafts.set': () => new Promise((resolve) => (finishSave = resolve)) }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('half a thought') + // The pause after typing starts the save, which the sidecar has not answered yet. + await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(1)) + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await new Promise((resolve) => setTimeout(resolve, 50)) + const flushed = () => invokeMock.mock.calls.some(([command]) => command === 'studio_flushed') + expect(flushed()).toBe(false) + await act(async () => finishSave({ rev: 1 })) + await waitFor(() => expect(flushed()).toBe(true)) + }) + it('keeps text typed while the draft before a send was still being saved', async () => { let finishSave: (value: unknown) => void = () => {} let saves = 0 diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index a85fb765..e981f2e3 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -88,7 +88,7 @@ export default function StudioApp() { * Messages whose send has not gone through, by project. Sending one again sends the same pictures under the same * request id, so a send whose reply was lost never sends it twice. */ - const pending = useRef(new Map()) + const pending = useRef(new Map()) /** Whether the person typed on the start screen, so the saved draft no longer fills it. */ const typedOnStart = useRef(false) /** Whether the person changed the start screen's pictures, so its saved ones no longer fill it. */ @@ -298,9 +298,13 @@ export default function StudioApp() { .catch((err: unknown) => setError(String(err))) } - // A quit or the window's close first saves the draft still waiting, then lets the quit go on. + // A quit or the window's close first saves the draft still waiting, and waits for every save already on its way, + // then lets the quit go on. useEffect(() => { - const unlisten = onQuitFlush(saveDraft) + const unlisten = onQuitFlush(async () => { + await saveDraft() + await Promise.all(Object.values(saving.current)) + }) return () => { void unlisten.then((stop) => stop()) } @@ -446,19 +450,20 @@ export default function StudioApp() { // Edits on the project's screen after this send went out make a newer draft. A project this send made had none // before it, so text typed while it was made counts as one. const projectEdits = createdHere ? 0 : fromEdits - const draftRev = createdHere ? (moved ? fromRev : undefined) : fromRev + // The sidecar removes the project's draft only if it is still the one this send went out with, so a draft the start + // screen did not move to the project, or a newer one, stays. + const draftRev = fromRev const outgoing = attachments + const paths = outgoing.map((a) => a.path) + // The same text and pictures as an unsent message is that message again, even with the pictures brought back from + // its saved draft, so it keeps its request id and the sidecar sends it once. const last = pending.current.get(id) - const again = - last !== undefined && - last.text === message && - last.outgoing.length === outgoing.length && - last.outgoing.every((a, i) => a === outgoing[i]) + const again = last !== undefined && last.text === message && last.paths.length === paths.length && last.paths.every((path, i) => path === paths[i]) const sendable = again ? last : { text: message, - outgoing, + paths, images: await Promise.all(outgoing.map((a) => studio.importFile(id, a.path))), id: crypto.randomUUID(), } From 9168046d9ab789eadf5083e6e85d496f88dbdb6c Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 16:19:27 -0600 Subject: [PATCH 48/59] fix(studio): an unsent message is named by its draft revision From Sol's review of 27f2c04: matching a retry by text and picture paths reused the old request id and imported picture when the person took a picture out and put a changed file back at the same path. The saved draft's revision is now the message's identity. Leaving the project and coming back keeps the revision, so a send again keeps its request id and pictures. Any edit, a picture taken out and put back included, saves a new revision, and so makes a new message. A first message's project takes the revision it went out with, so a picture-only first send retried from its project keeps its request id too. Co-Authored-By: Claude Opus 5.5 --- src/studio/StudioApp.test.tsx | 48 +++++++++++++++++++++++++++++++++-- src/studio/StudioApp.tsx | 28 +++++++++----------- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 3c9e1b93..c98ca5b9 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -57,7 +57,7 @@ type Calls = Record) => Promise = {}) { +function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Record = {}) { invokeMock.mockImplementation((command: string, args: { method: string; params: Record }) => { if (command === 'update_check') return Promise.resolve({ status: 'upToDate' }) if (command === 'studio_files') return Promise.resolve([]) @@ -537,7 +537,7 @@ describe('StudioApp', () => { studio_import_file: () => Promise.resolve('refs/pic.jpg'), }, [projectA, projectB], - { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'] } }, + { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'], rev: 7 } }, ) render() fireEvent.click(await screen.findByText('Project A')) @@ -554,6 +554,50 @@ describe('StudioApp', () => { expect(second?.requestId).toBe(first?.requestId) }) + it('sends a picture taken out and put back after a lost answer as a new message, as it may be a new file', async () => { + let sends = 0 + let imports = 0 + mockSidecar( + { + 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), + studio_import_file: () => Promise.resolve(`refs/pic-${++imports}.jpg`), + }, + [projectA, projectB], + { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'], rev: 7 } }, + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip like this')) + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + fireEvent.click(screen.getByLabelText('Remove pic.jpg')) + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/pic.jpg'] } }))) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + const [first, second] = sidecarCalls('chat.send') + expect(second?.requestId).not.toBe(first?.requestId) + expect(second?.attachments).toEqual(['refs/pic-2.jpg']) + }) + + it('sends a first message of only a picture again under the same request id from the project it made', async () => { + let sends = 0 + mockSidecar({ + 'projects.create': () => Promise.resolve({ id: '2026-11-06-new-part', conversation: 4, title: 'New part', createdMs: 3 }), + 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), + studio_import_file: () => Promise.resolve('refs/pic.jpg'), + }) + render() + await screen.findByText('Project B') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/pic.jpg'] } }))) + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + const [first, second] = sidecarCalls('chat.send') + expect(second?.requestId).toBe(first?.requestId) + }) + it('names the start draft a first send went out with, even when the person saved a newer one and left before the project was made', async () => { let finishCreate: (project: unknown) => void = () => {} const revs: number[] = [] diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index e981f2e3..aea03a5f 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -85,10 +85,11 @@ export default function StudioApp() { /** A draft not saved yet, and the timer that saves it. */ const unsaved = useRef<{ key: string; draft: Draft; timer: number } | null>(null) /** - * Messages whose send has not gone through, by project. Sending one again sends the same pictures under the same - * request id, so a send whose reply was lost never sends it twice. + * Messages whose send has not gone through, by project, each named by the revision of the saved draft it went out + * as. The same revision is the same message: it goes out again with the same pictures under the same request id, so a + * send whose reply was lost is never sent twice. Any edit saves a new revision, and so makes a new message. */ - const pending = useRef(new Map()) + const pending = useRef(new Map()) /** Whether the person typed on the start screen, so the saved draft no longer fills it. */ const typedOnStart = useRef(false) /** Whether the person changed the start screen's pictures, so its saved ones no longer fill it. */ @@ -439,6 +440,9 @@ export default function StudioApp() { for (const [key, value] of [[created.id, draft], [START, empty]] as const) { void storeDraft(key, value).catch((err: unknown) => setError(String(err))) } + } else if (fromRev !== undefined) { + // The project's composer holds what went out, so a send again from it is the same message. + revs.current[created.id] = fromRev } show(created, true) request = loadRequest.current @@ -454,20 +458,12 @@ export default function StudioApp() { // screen did not move to the project, or a newer one, stays. const draftRev = fromRev const outgoing = attachments - const paths = outgoing.map((a) => a.path) - // The same text and pictures as an unsent message is that message again, even with the pictures brought back from - // its saved draft, so it keeps its request id and the sidecar sends it once. const last = pending.current.get(id) - const again = last !== undefined && last.text === message && last.paths.length === paths.length && last.paths.every((path, i) => path === paths[i]) - const sendable = again - ? last - : { - text: message, - paths, - images: await Promise.all(outgoing.map((a) => studio.importFile(id, a.path))), - id: crypto.randomUUID(), - } - pending.current.set(id, sendable) + const sendable = + last !== undefined && last.rev === fromRev + ? last + : { images: await Promise.all(outgoing.map((a) => studio.importFile(id, a.path))), id: crypto.randomUUID() } + if (fromRev !== undefined) pending.current.set(id, { ...sendable, rev: fromRev }) await sidecar('chat.send', { project: id, requestId: sendable.id, From 488f33d0e576823b7079c2025c6dfeef1dc49922 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 16:27:35 -0600 Subject: [PATCH 49/59] fix(sidecar): the toolchain check never holds up the sidecar, and a stray file in pictures never stops its start From CodeRabbit's review of 47918da, each with a test that failed first. - The uv and Python checks run beside the sidecar's other work, each limited to 5 s. A check still running then counts as failed, so a stuck `uv python find` holds up no chat. - The picture cleanup at start passes over anything in the pictures folder that is not a picture's own folder, such as a .DS_Store Finder leaves. It threw before. The models' hash of app.ts is recorded again. TLC: every verdict as recorded. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 2 +- sidecar/src/agent.ts | 19 ++++++++++----- sidecar/src/app.ts | 7 ++++-- sidecar/src/methods.ts | 2 +- sidecar/test/switch.test.ts | 23 ++++++++++++++++++- sidecar/test/tools.test.ts | 17 ++++++++++++++ 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 0b33d48c..d165d604 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -7,4 +7,4 @@ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/ b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts fccd0d9fb5629626d0ada99746dfb3805381238eadfeedb7c6f375a491123828 sidecar/src/chat.ts -389fba635222a03e2284be87fe3eb4676feafda7754aba26b56372f46c47503d sidecar/src/app.ts +6d4eb78b893da94d0e56e02ea465b790981ae7d64fcce361d15ea42fea3c17d4 sidecar/src/app.ts diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index fd551cf4..5b01cf6c 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -61,10 +61,17 @@ function commandEnv(): Record { return vars } -/** Whether `command` runs to a zero exit in the environment the agent's commands get. A missing program does not. */ -function succeeds(command: string[]): boolean { +/** How long one toolchain check may run. A check still running then, as one stuck on a lock, counts as failed. */ +const CHECK_MS = 5_000 + +/** + * Whether `command` runs to a zero exit, within `CHECK_MS`, in the environment the agent's commands get. A missing + * program does not. It runs beside the sidecar's other work, so a stuck check holds up no chat. + */ +async function succeeds(command: string[]): Promise { try { - return Bun.spawnSync(command, { env: commandEnv(), stdout: 'ignore', stderr: 'ignore' }).exitCode === 0 + const child = Bun.spawn(command, { env: commandEnv(), stdout: 'ignore', stderr: 'ignore', timeout: CHECK_MS }) + return (await child.exited) === 0 } catch { return false } @@ -75,11 +82,11 @@ function succeeds(command: string[]): boolean { * that uv can run them with. A Python uv would have to download first counts as missing, as a turn could not count on * the network for it. */ -export function toolchainNeeds(): Need[] { - if (!succeeds(['uv', '--version'])) { +export async function toolchainNeeds(): Promise { + if (!(await succeeds(['uv', '--version']))) { return [{ kind: 'uv', fix: 'Install uv, the tool the agent runs its build scripts with (docs.astral.sh/uv), then check again.' }] } - if (!succeeds(['uv', 'python', 'find'])) { + if (!(await succeeds(['uv', 'python', 'find']))) { return [{ kind: 'python', fix: 'Install a Python for uv: run "uv python install" in Terminal, then check again.' }] } return [] diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index 4be01019..a521d62b 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' +import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { parseArgs } from 'node:util' import type { MutableModels } from '@earendil-works/pi-ai' @@ -109,7 +109,10 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis const dayAgo = Date.now() - 86_400_000 for (const folder of existsSync(pictures) ? readdirSync(pictures) : []) { const path = join(pictures, folder) - if (statSync(path).mtimeMs > dayAgo) continue + // Each pasted picture has a folder of its own. Anything else here, as a file Finder leaves or a link, is not the + // sidecar's, so it is read without following it. + const stat = lstatSync(path) + if (!stat.isDirectory() || stat.mtimeMs > dayAgo) continue if (!readdirSync(path).some((file) => named.has(join(path, file)))) rmSync(path, { recursive: true, force: true }) } const legacy = { diff --git a/sidecar/src/methods.ts b/sidecar/src/methods.ts index 8d6e7a04..68e63e5e 100644 --- a/sidecar/src/methods.ts +++ b/sidecar/src/methods.ts @@ -59,7 +59,7 @@ export function handlers({ store, logins, chats, version, legacy, pictures }: Se : login.login === null ? [{ kind: 'login', provider: login.provider, name: login.name, fix: `Sign in to ${login.name} before you send.` }] : [] - return [...needs, ...toolchainNeeds()] + return [...needs, ...(await toolchainNeeds())] }, }, 'legacy.import': { params: compile(obj({})), run: () => legacy.retry() }, diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index 62167e07..57f34839 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import type { ChatEntry, ChatView } from '../src/protocol' @@ -74,6 +74,27 @@ describe('a picture pasted into a draft', () => { expect(existsSync(dropped)).toBe(false) await third.quit() }) + + test('the cleanup at start passes over a file beside the picture folders, such as one Finder leaves', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + mkdirSync(join(dir, 'pictures')) + const stray = join(dir, 'pictures', '.DS_Store') + writeFileSync(stray, '') + utimesSync(stray, new Date(Date.now() - 2 * 86_400_000), new Date(Date.now() - 2 * 86_400_000)) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + expect(await app.next()).toMatchObject({ kind: 'event', event: { type: 'ready' } }) + expect(existsSync(stray)).toBe(true) + await app.quit() + }) + + test('the cleanup at start passes over a link to nothing in the pictures folder', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + mkdirSync(join(dir, 'pictures')) + symlinkSync(join(dir, 'gone'), join(dir, 'pictures', 'broken')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + expect(await app.next()).toMatchObject({ kind: 'event', event: { type: 'ready' } }) + await app.quit() + }) }) describe('the import of chats from before the store', () => { diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index 2850539d..87441ed2 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -157,6 +157,23 @@ describe('the check before a send', () => { expect(await check('/usr/bin:/bin', true, home)).toEqual([]) }) + test('a check that hangs ends at its limit, and names what it could not find', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-uv-')) + writeFileSync(join(dir, 'uv'), '#!/bin/sh\n[ "$1" = --version ] && { echo "uv 0.9.0"; exit 0; }\nexec sleep 60\n') + chmodSync(join(dir, 'uv'), 0o755) + const state = mkdtempSync(join(tmpdir(), 'm3d-check-')) + const app = spawnSidecar(join(state, 'state'), join(state, 'projects'), new Map(), { + env: { PATH: `${dir}:/usr/bin:/bin`, HOME: mkdtempSync(join(tmpdir(), 'm3d-home-')) }, + }) + await app.next() + await app.call('auth.setKey', { provider: 'anthropic', key: 'token' }) + const started = Date.now() + const needs = (await app.call('setup.check', {})) as { kind: string }[] + expect(Date.now() - started).toBeLessThan(10_000) + expect(needs.map((need) => need.kind)).toEqual(['python']) + await app.quit() + }, 30_000) + test('names a missing Python once uv is there', async () => { expect((await check(toolchain(false), true)).map((need) => need.kind)).toEqual(['python']) }) From a6c2c80b86f2acfc1c8aa9c842731a6f5e075673 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 16:37:49 -0600 Subject: [PATCH 50/59] fix(studio): an unsent message is named by its screen's edit count, and a new project says which draft it took From Sol's review of 9168046: a revision as the message's identity reused the old request id when the change's own draft save failed, and a new project's draft was guessed from the window's cache, so a start draft changed while the project was made could take the first message's revision. - A message's identity is now the count of edits to its project's composer when it went out. The window counts each edit as it happens, saved or not, and moving between screens is no edit. A send at the same count reuses its request id and pictures. Any edit is a new message. Draft revisions only name the draft a send consumes. - projects.create answers with the start screen draft the sidecar took, or null. The window shows that draft in the project and keeps a start screen draft changed since. - The window tests' sidecar mock moves the start draft by the sidecar's own rule. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/protocol.ts | 4 +- sidecar/src/store.ts | 22 ++++--- sidecar/test/store.test.ts | 11 ++-- src/studio/StudioApp.test.tsx | 115 ++++++++++++++++++++++++++++++---- src/studio/StudioApp.tsx | 34 +++++----- 5 files changed, 143 insertions(+), 43 deletions(-) diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index ce3bb49d..683683c2 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -21,6 +21,8 @@ export type Settings = { /** `conversation` is the project's Pi conversation id. */ export type Project = { id: string; conversation: number; title: string; createdMs: number } +/** A project just made, and the start screen's draft it took as its own, if it took one. */ +export type CreatedProject = Project & { draft: SavedDraft | null } /** An unsent message, by project id, or `new` for the start screen. Attachments are paths inside the project. */ export type Draft = { text: string; attachments: string[] } @@ -121,7 +123,7 @@ export interface Methods { 'setup.check': { params: Record; result: Need[] } /** Imports the chats from before the store again, as after the person let the app read the projects folder. */ 'legacy.import': { params: Record; result: { imported: number; blocked: string | null } } - 'projects.create': { params: { title: string }; result: Project } + 'projects.create': { params: { title: string }; result: CreatedProject } 'auth.status': { params: Record; result: Login[] } /** A key, or for Anthropic a proxy's base URL and token. */ 'auth.setKey': { params: { provider: ProviderId; key: string; baseUrl?: string }; result: Login[] } diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 9b62fad6..d49618f5 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -13,7 +13,7 @@ import { type Conversation, defineDoc, type HarnessOptions, Harness, type Regist import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node' import type { LoginIndex } from './inference' import type { LegacyProject } from './legacy' -import type { Draft, LoginKind, Project, SavedDraft, Settings, Setup, StoredState, Turn, Waiting } from './protocol' +import type { CreatedProject, Draft, LoginKind, Project, SavedDraft, Settings, Setup, StoredState, Turn, Waiting } from './protocol' const context = BACKGROUND_CONTEXT @@ -57,7 +57,7 @@ const ProjectsDoc = defineDoc<{ projects: Project[] }>({ checkpointWhen: always, }) -export const DraftsDoc = defineDoc<{ drafts: Record; seq?: number }>({ +export const DraftsDoc = defineDoc<{ drafts: Record; seq?: number }>({ kind: 'm3d.drafts', version: 1, scope: 'conversation', @@ -227,7 +227,8 @@ export class Store implements LoginIndex { async reviseOldDrafts(): Promise { await this.harness.commit(async (tx) => { const doc = await tx.doc(DraftsDoc, this.root.id) - for (const draft of Object.values(doc.drafts)) { + // A store from before revisions holds drafts without one, which this read is the only place to see. + for (const draft of Object.values(doc.drafts) as (Draft & { rev?: number })[]) { if (draft.rev !== undefined) continue doc.seq = (doc.seq ?? 0) + 1 draft.rev = doc.seq @@ -239,9 +240,10 @@ export class Store implements LoginIndex { * Makes a project: its conversation, its place in the index, and the start screen's draft moved to it when that * draft holds the project's first message, in one commit. A start screen draft with other text is the person's next * idea, so it stays. The folder is made inside that commit, before anything is kept, so a folder that cannot be made - * keeps nothing. A crash after the folder leaves an empty folder, which the next id skips. + * keeps nothing. A crash after the folder leaves an empty folder, which the next id skips. It answers with the draft it + * moved, so the window shows what the project holds rather than guessing. */ - async createProject(title: string): Promise { + async createProject(title: string): Promise { const firstLine = (title.split('\n')[0] ?? '').trim().slice(0, 80) || 'New part' const now = new Date() // The person's own date, as the folder name shows it. @@ -259,11 +261,11 @@ export class Store implements LoginIndex { index.projects.push(created) const { drafts } = await tx.doc(DraftsDoc, this.root.id) const draft = drafts.new - if (draft !== undefined && draft.text.trim() === title.trim()) { - drafts[id] = draft - delete drafts.new - } - return created + if (draft === undefined || draft.text.trim() !== title.trim()) return { ...created, draft: null } + drafts[id] = draft + delete drafts.new + // A plain copy, since the documents a commit hands out are only valid inside it. + return { ...created, draft: { text: draft.text, attachments: [...draft.attachments], rev: draft.rev } } }, context) return project } diff --git a/sidecar/test/store.test.ts b/sidecar/test/store.test.ts index 7017c4f1..ea0ab1f9 100644 --- a/sidecar/test/store.test.ts +++ b/sidecar/test/store.test.ts @@ -132,7 +132,7 @@ describe('app state', () => { const app = spawnSidecar(data, projects) await app.next() await app.call('drafts.set', { key: 'new', text: 'A shelf bracket', attachments: [] }) - await app.call('projects.create', { title: 'A knob' }) + expect(((await app.call('projects.create', { title: 'A knob' })) as { draft: unknown }).draft).toBeNull() expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ new: { text: 'A shelf bracket', attachments: [], rev: expect.any(Number) } }) await app.quit() }) @@ -149,7 +149,8 @@ describe('app state', () => { }, }) await old.harness.commit(async (tx) => { - ;(await tx.doc(DraftsDoc, old.root.id)).drafts.new = { text: 'A knob', attachments: [] } + // A draft as a store from before revisions holds it. + ;((await tx.doc(DraftsDoc, old.root.id)).drafts as Record).new = { text: 'A knob', attachments: [] } }, BACKGROUND_CONTEXT) await old.close() @@ -167,8 +168,10 @@ describe('app state', () => { const { data, projects } = scratch() const app = spawnSidecar(data, projects) await app.next() - await app.call('drafts.set', { key: 'new', text: 'A knob', attachments: ['refs/shaft.png'] }) - const project = (await app.call('projects.create', { title: 'A knob' })) as { id: string } + const { rev } = (await app.call('drafts.set', { key: 'new', text: 'A knob', attachments: ['refs/shaft.png'] })) as { rev: number } + const project = (await app.call('projects.create', { title: 'A knob' })) as { id: string; draft: unknown } + // It answers with the draft it took, so the window need not guess. + expect(project.draft).toEqual({ text: 'A knob', attachments: ['refs/shaft.png'], rev }) expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ [project.id]: { text: 'A knob', attachments: ['refs/shaft.png'], rev: expect.any(Number) } }) await app.quit() }) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index c98ca5b9..f9aac686 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -58,27 +58,57 @@ type Calls = Record) => Promise = {}) { - invokeMock.mockImplementation((command: string, args: { method: string; params: Record }) => { - if (command === 'update_check') return Promise.resolve({ status: 'upToDate' }) - if (command === 'studio_files') return Promise.resolve([]) - if (overrides[command]) return overrides[command](args as unknown as Record) - if (command !== 'sidecar') return Promise.resolve(undefined) - const override = overrides[args.method] - if (override) return override(args.params) - switch (args.method) { + // The drafts the sidecar holds, as the window saved them, so a new project takes the start screen's draft by the + // sidecar's own rule: when its text is the project's title. + const held: Record = {} + for (const [key, draft] of Object.entries(drafts)) held[key] = { text: draft.text, attachments: draft.attachments ?? [], rev: draft.rev ?? 0 } + const call = (method: string, params: Record): Promise => { + const override = overrides[method] + if (method === 'drafts.set') { + const saved = override ? override(params) : Promise.resolve({ rev: ++draftRevs }) + return saved.then((value) => { + const { rev } = value as { rev: number } + const draft = { text: String(params.text), attachments: params.attachments as string[], rev } + if (draft.text === '' && draft.attachments.length === 0) delete held[String(params.key)] + else held[String(params.key)] = draft + return value + }) + } + if (method === 'projects.create') { + const made = override ? override(params) : Promise.resolve(null) + return made.then((project) => { + if (project === null || typeof project !== 'object' || 'draft' in project) return project + const id = (project as { id: string }).id + const start = held.new + if (start === undefined || start.text.trim() !== String(params.title).trim()) return { ...project, draft: null } + held[id] = start + delete held.new + return { ...project, draft: start } + }) + } + if (override) return override(params) + return defaults(method, params) + } + const defaults = (method: string, params: Record): Promise => { + switch (method) { case 'app.ping': return Promise.resolve({ version: '1.2.3' }) case 'state.get': return Promise.resolve({ setup: { step: 'ready', done: true }, settings, projects, drafts, importBlocked: null }) case 'chat.open': - return Promise.resolve(view(String(args.params.project))) + return Promise.resolve(view(String(params.project))) case 'setup.check': return Promise.resolve([]) - case 'drafts.set': - return Promise.resolve({ rev: ++draftRevs }) default: return Promise.resolve(null) } + } + invokeMock.mockImplementation((command: string, args: { method: string; params: Record }) => { + if (command === 'update_check') return Promise.resolve({ status: 'upToDate' }) + if (command === 'studio_files') return Promise.resolve([]) + if (overrides[command]) return overrides[command](args as unknown as Record) + if (command !== 'sidecar') return Promise.resolve(undefined) + return call(args.method, args.params) }) } @@ -598,6 +628,69 @@ describe('StudioApp', () => { expect(second?.requestId).toBe(first?.requestId) }) + it('sends text changed after a lost answer as a new message, even when the change could not be saved', async () => { + let sends = 0 + let saves = 0 + mockSidecar( + { + 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), + 'drafts.set': () => (++saves === 2 ? Promise.reject('The engine is starting.') : Promise.resolve({ rev: 100 + saves })), + }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + type('A clip for my shelf') + // The pause after typing saves the change, and that save fails. + await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(2)) + await new Promise((resolve) => setTimeout(resolve, 20)) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + const [first, second] = sidecarCalls('chat.send') + expect(second).toMatchObject({ text: 'A clip for my shelf' }) + expect(second?.requestId).not.toBe(first?.requestId) + }) + + it('opens a first message\'s project with the draft the sidecar moved there, not a newer start draft', async () => { + let finishCreate: (project: unknown) => void = () => {} + let sends = 0 + let imports = 0 + mockSidecar({ + 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)), + 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), + studio_import_file: () => Promise.resolve(`refs/pic-${++imports}.jpg`), + }) + render() + await screen.findByText('Project B') + type('A knob') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/a.jpg'] } }))) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) + const sentRev = draftRevs + // While the project is made, the person swaps the picture and leaves. + fireEvent.click(screen.getByLabelText('Remove a.jpg')) + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/b.jpg'] } }))) + fireEvent.click(screen.getByText('Project B')) + const made = { id: '2026-11-06-a-knob', conversation: 4, title: 'A knob', createdMs: 3 } + await act(async () => finishCreate({ ...made, draft: { text: 'A knob', attachments: ['/Users/me/a.jpg'], rev: sentRev } })) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + fireEvent.click(await screen.findByText('A knob')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('A knob')) + expect(screen.getByTestId('attachments').textContent).toContain('a.jpg') + expect(screen.getByTestId('attachments').textContent).not.toContain('b.jpg') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + const [first, second] = sidecarCalls('chat.send') + expect(second?.requestId).toBe(first?.requestId) + // The start screen keeps the newer draft with b.jpg. + fireEvent.click(screen.getByTestId('new-project')) + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('b.jpg')) + }) + it('names the start draft a first send went out with, even when the person saved a newer one and left before the project was made', async () => { let finishCreate: (project: unknown) => void = () => {} const revs: number[] = [] diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index aea03a5f..15b840ef 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -85,11 +85,12 @@ export default function StudioApp() { /** A draft not saved yet, and the timer that saves it. */ const unsaved = useRef<{ key: string; draft: Draft; timer: number } | null>(null) /** - * Messages whose send has not gone through, by project, each named by the revision of the saved draft it went out - * as. The same revision is the same message: it goes out again with the same pictures under the same request id, so a - * send whose reply was lost is never sent twice. Any edit saves a new revision, and so makes a new message. + * Messages whose send has not gone through, by project, each with the count of edits to the project's composer it went + * out at. A send at that same count is the same message: it goes out again with the same pictures under the same + * request id, so a send whose reply was lost is never sent twice. Any edit, saved or not, makes a new message, and + * moving between screens is no edit. */ - const pending = useRef(new Map()) + const pending = useRef(new Map()) /** Whether the person typed on the start screen, so the saved draft no longer fills it. */ const typedOnStart = useRef(false) /** Whether the person changed the start screen's pictures, so its saved ones no longer fill it. */ @@ -417,14 +418,16 @@ export default function StudioApp() { let moved = false let project = current if (!project) { - const title = message || 'New part' - const created = await sidecar('projects.create', { title }) + const { draft: took, ...created } = await sidecar('projects.create', { title: message || 'New part' }) project = created - const { [START]: started, ...rest } = drafts.current - moved = started !== undefined && started.text.trim() === title - if (moved && started !== undefined) { - drafts.current = { ...rest, [created.id]: started } - if (fromRev !== undefined) revs.current[created.id] = fromRev + // The project holds the start screen's draft only if the sidecar says it took it. The start screen's own draft, + // if the person changed it since this send, is newer, and stays. + if (took !== null) { + moved = true + const { rev, ...draft } = took + drafts.current = { ...drafts.current, [created.id]: draft } + revs.current[created.id] = rev + if ((edits.current[START] ?? 0) === fromEdits) drafts.current = { ...drafts.current, [START]: empty } } setProjects((prev) => [created, ...prev]) if (loadRequest.current === request) { @@ -440,9 +443,6 @@ export default function StudioApp() { for (const [key, value] of [[created.id, draft], [START, empty]] as const) { void storeDraft(key, value).catch((err: unknown) => setError(String(err))) } - } else if (fromRev !== undefined) { - // The project's composer holds what went out, so a send again from it is the same message. - revs.current[created.id] = fromRev } show(created, true) request = loadRequest.current @@ -460,10 +460,10 @@ export default function StudioApp() { const outgoing = attachments const last = pending.current.get(id) const sendable = - last !== undefined && last.rev === fromRev + last !== undefined && last.edits === projectEdits ? last - : { images: await Promise.all(outgoing.map((a) => studio.importFile(id, a.path))), id: crypto.randomUUID() } - if (fromRev !== undefined) pending.current.set(id, { ...sendable, rev: fromRev }) + : { edits: projectEdits, images: await Promise.all(outgoing.map((a) => studio.importFile(id, a.path))), id: crypto.randomUUID() } + pending.current.set(id, sendable) await sidecar('chat.send', { project: id, requestId: sendable.id, From b35114b7774b3f42f798f4d6627013aa92e80733 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 16:53:04 -0600 Subject: [PATCH 51/59] fix(studio): a message is its draft's revision, known only while the draft is unchanged Redesign from the original problem, under land-pr's convergence rule: four reviews of message identity kept finding cases, because each model guessed whether the composer still held what went out (object identity, picture paths, a fallback revision, an edit count). - `revs[key]` is the revision of a screen's draft exactly as it is. Every change clears it at once, and a save sets it only if nothing changed while the save ran. A send goes out as `draft-`: the known revision, or the one a save of what went out gives now. A failed save fails the send, so changed content never takes an old identity. - projects.create takes that revision and makes one project per revision, so a first message sent again from the start screen finds its project, and the sidecar sends it once. - The moved draft comes from the sidecar with its own revision, so a newer draft it took is a new message. - A paste that lands after the person left goes into the draft of the screen it came from. - After a send, each screen whose revision is still the sent one empties. The project's newer draft drops only the sent pictures, and a newer start screen draft stays. Tests for Sol's three cases failed first. The window mock now makes a new project per create call and finds the old one by revision, as the sidecar does. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/methods.ts | 2 +- sidecar/src/protocol.ts | 2 +- sidecar/src/store.ts | 12 ++- sidecar/test/store.test.ts | 13 +++ src/studio/StudioApp.test.tsx | 71 ++++++++++++- src/studio/StudioApp.tsx | 193 ++++++++++++++++------------------ 6 files changed, 185 insertions(+), 108 deletions(-) diff --git a/sidecar/src/methods.ts b/sidecar/src/methods.ts index 68e63e5e..316b7218 100644 --- a/sidecar/src/methods.ts +++ b/sidecar/src/methods.ts @@ -79,7 +79,7 @@ export function handlers({ store, logins, chats, version, legacy, pictures }: Se params: compile(obj({ key: str(), text: str(), attachments: arr(str()) })), run: async ({ key, text, attachments }) => ({ rev: await store.setDraft(key, { text, attachments }) }), }, - 'projects.create': { params: compile(obj({ title: str() })), run: ({ title }) => store.createProject(title) }, + 'projects.create': { params: compile(obj({ title: str(), rev: opt(int()) })), run: ({ title, rev }) => store.createProject(title, rev) }, 'auth.status': { params: compile(obj({})), run: () => logins.status() }, 'auth.setKey': { params: compile(obj({ provider, key: str({ minLength: 1 }), baseUrl: opt(str({ minLength: 1 })) })), diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index 683683c2..cf8c6407 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -123,7 +123,7 @@ export interface Methods { 'setup.check': { params: Record; result: Need[] } /** Imports the chats from before the store again, as after the person let the app read the projects folder. */ 'legacy.import': { params: Record; result: { imported: number; blocked: string | null } } - 'projects.create': { params: { title: string }; result: CreatedProject } + 'projects.create': { params: { title: string; rev?: number }; result: CreatedProject } 'auth.status': { params: Record; result: Login[] } /** A key, or for Anthropic a proxy's base URL and token. */ 'auth.setKey': { params: { provider: ProviderId; key: string; baseUrl?: string }; result: Login[] } diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index d49618f5..b57baf12 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -47,7 +47,8 @@ export const SettingsDoc = defineDoc({ checkpointWhen: always, }) -const ProjectsDoc = defineDoc<{ projects: Project[] }>({ +/** The projects, and the project each first message's draft revision made, so a send of it again makes no other. */ +const ProjectsDoc = defineDoc<{ projects: Project[]; madeFor?: Record }>({ kind: 'm3d.projects', version: 1, scope: 'conversation', @@ -241,9 +242,10 @@ export class Store implements LoginIndex { * draft holds the project's first message, in one commit. A start screen draft with other text is the person's next * idea, so it stays. The folder is made inside that commit, before anything is kept, so a folder that cannot be made * keeps nothing. A crash after the folder leaves an empty folder, which the next id skips. It answers with the draft it - * moved, so the window shows what the project holds rather than guessing. + * moved, so the window shows what the project holds rather than guessing. A first message's draft revision makes one + * project only: asked again for it, as after an answer that was lost, it answers with that project. */ - async createProject(title: string): Promise { + async createProject(title: string, rev?: number): Promise { const firstLine = (title.split('\n')[0] ?? '').trim().slice(0, 80) || 'New part' const now = new Date() // The person's own date, as the folder name shows it. @@ -251,6 +253,9 @@ export class Store implements LoginIndex { const base = `${today}-${slug(firstLine)}` const project = await this.harness.commit(async (tx) => { const index = await tx.doc(ProjectsDoc, this.root.id) + const before = rev === undefined ? undefined : index.madeFor?.[rev] + const again = index.projects.find((p) => p.id === before) + if (again !== undefined) return { ...again, draft: null } const taken = new Set(index.projects.map((p) => p.id)) let id = base // A folder already on disk, which the store may not list, is never taken over. @@ -259,6 +264,7 @@ export class Store implements LoginIndex { const conversation = await tx.createConversation({ ownership: { kind: 'ownerless' } }) const created: Project = { id, conversation: conversation.id, title: firstLine, createdMs: now.getTime() } index.projects.push(created) + if (rev !== undefined) index.madeFor = { ...index.madeFor, [rev]: id } const { drafts } = await tx.doc(DraftsDoc, this.root.id) const draft = drafts.new if (draft === undefined || draft.text.trim() !== title.trim()) return { ...created, draft: null } diff --git a/sidecar/test/store.test.ts b/sidecar/test/store.test.ts index ea0ab1f9..03300272 100644 --- a/sidecar/test/store.test.ts +++ b/sidecar/test/store.test.ts @@ -127,6 +127,19 @@ describe('app state', () => { await app.quit() }) + test("a first message's draft revision makes one project, even when asked again", async () => { + const { data, projects } = scratch() + const app = spawnSidecar(data, projects) + await app.next() + const first = (await app.call('projects.create', { title: 'New part', rev: 7 })) as { id: string } + const again = (await app.call('projects.create', { title: 'New part', rev: 7 })) as { id: string; draft: unknown } + expect(again).toMatchObject({ id: first.id, draft: null }) + expect(((await app.call('state.get')) as { projects: unknown[] }).projects).toHaveLength(1) + const other = (await app.call('projects.create', { title: 'New part', rev: 8 })) as { id: string } + expect(other.id).not.toBe(first.id) + await app.quit() + }) + test('a project made from the start screen leaves a start screen draft that holds other text where it is', async () => { const { data, projects } = scratch() const app = spawnSidecar(data, projects) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index f9aac686..f313a20e 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -61,6 +61,7 @@ function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Recor // The drafts the sidecar holds, as the window saved them, so a new project takes the start screen's draft by the // sidecar's own rule: when its text is the project's title. const held: Record = {} + const madeFor = new Map() for (const [key, draft] of Object.entries(drafts)) held[key] = { text: draft.text, attachments: draft.attachments ?? [], rev: draft.rev ?? 0 } const call = (method: string, params: Record): Promise => { const override = overrides[method] @@ -75,9 +76,13 @@ function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Recor }) } if (method === 'projects.create') { + const again = typeof params.rev === 'number' ? madeFor.get(params.rev) : undefined + if (again !== undefined) return Promise.resolve({ ...again, draft: null }) const made = override ? override(params) : Promise.resolve(null) return made.then((project) => { - if (project === null || typeof project !== 'object' || 'draft' in project) return project + if (project === null || typeof project !== 'object') return project + if (typeof params.rev === 'number') madeFor.set(params.rev, project as object) + if ('draft' in project) return project const id = (project as { id: string }).id const start = held.new if (start === undefined || start.text.trim() !== String(params.title).trim()) return { ...project, draft: null } @@ -378,6 +383,10 @@ describe('StudioApp', () => { await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) await act(async () => finishPicture('/state/pictures/1/shot.png')) expect(screen.queryByTestId('attachments')).toBeNull() + // It lands in the draft of the screen it was pasted on, and is there when the person goes back. + await waitFor(() => expect(sidecarCalls('drafts.set')).toContainEqual({ key: projectA.id, text: '', attachments: ['/state/pictures/1/shot.png'] })) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('shot.png')) }) it("brings the start screen's saved pictures back even when the person typed before the saved state arrived", async () => { @@ -691,6 +700,66 @@ describe('StudioApp', () => { await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('b.jpg')) }) + it('sends a newer draft the sidecar moved to a first message\'s project as a new message', async () => { + let finishCreate: (project: unknown) => void = () => {} + let sends = 0 + let imports = 0 + const revs: number[] = [] + mockSidecar({ + 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)), + 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), + 'drafts.set': () => { + revs.push(++draftRevs) + return Promise.resolve({ rev: draftRevs }) + }, + studio_import_file: () => Promise.resolve(`refs/pic-${++imports}.jpg`), + }) + render() + await screen.findByText('Project B') + type('A knob') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/a.jpg'] } }))) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) + fireEvent.click(screen.getByLabelText('Remove a.jpg')) + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/b.jpg'] } }))) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(revs).toHaveLength(2)) + const made = { id: '2026-11-06-a-knob', conversation: 4, title: 'A knob', createdMs: 3 } + // The sidecar made the project after the newer draft was saved, so it took that one. + await act(async () => finishCreate({ ...made, draft: { text: 'A knob', attachments: ['/Users/me/b.jpg'], rev: revs[1] } })) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + fireEvent.click(await screen.findByText('A knob')) + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('b.jpg')) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + const [first, second] = sidecarCalls('chat.send') + expect(second?.requestId).not.toBe(first?.requestId) + expect(second?.attachments).toEqual(['refs/pic-2.jpg']) + }) + + it('sends a picture-only first message again from the start screen into the same project under the same request id', async () => { + let sends = 0 + let made = 0 + mockSidecar({ + // Each call makes a project of its own, as the sidecar does. + 'projects.create': () => Promise.resolve({ id: `2026-11-06-new-part-${++made}`, conversation: 3 + made, title: 'New part', createdMs: 3 }), + 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), + studio_import_file: () => Promise.resolve('refs/pic.jpg'), + }) + render() + await screen.findByText('Project B') + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/pic.jpg'] } }))) + fireEvent.click(screen.getByTestId('send')) + await screen.findByRole('alert') + fireEvent.click(screen.getByTestId('new-project')) + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('pic.jpg')) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + const [first, second] = sidecarCalls('chat.send') + expect(second?.project).toBe(first?.project) + expect(second?.requestId).toBe(first?.requestId) + }) + it('names the start draft a first send went out with, even when the person saved a newer one and left before the project was made', async () => { let finishCreate: (project: unknown) => void = () => {} const revs: number[] = [] diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 15b840ef..4ca54cda 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -84,13 +84,8 @@ export default function StudioApp() { const drafts = useRef>({}) /** A draft not saved yet, and the timer that saves it. */ const unsaved = useRef<{ key: string; draft: Draft; timer: number } | null>(null) - /** - * Messages whose send has not gone through, by project, each with the count of edits to the project's composer it went - * out at. A send at that same count is the same message: it goes out again with the same pictures under the same - * request id, so a send whose reply was lost is never sent twice. Any edit, saved or not, makes a new message, and - * moving between screens is no edit. - */ - const pending = useRef(new Map()) + /** The pictures a send imported into its project, by the draft revision it went out as, for a send of it again. */ + const imported = useRef(new Map()) /** Whether the person typed on the start screen, so the saved draft no longer fills it. */ const typedOnStart = useRef(false) /** Whether the person changed the start screen's pictures, so its saved ones no longer fill it. */ @@ -99,12 +94,13 @@ export default function StudioApp() { const composerText = useRef('') /** The project whose chat view is loaded. Chat changes for the open project wait in `early` until it is. */ const loaded = useRef(null) + /** Counts the changes to each screen's draft, so a save knows whether the draft changed while it was on its way. */ + const edits = useRef>({}) /** - * The person's edits to each screen's composer, text or pictures, so a send clears a screen only when nothing changed - * there since it went out. + * The revision the sidecar gave each screen's draft as it is now, or none when the draft changed since its last save. + * It is the message's identity: a send goes out as `draft-`, so a send of the same draft again, as after a + * lost answer, is the same message, and the sidecar sends it once. Any change is a new message. */ - const edits = useRef>({}) - /** The revision of each screen's saved draft, so a send names the draft it went out with. */ const revs = useRef>({}) /** The last save the window started for each screen's draft, and the revision it got, or none if it failed. */ const saving = useRef>>({}) @@ -128,8 +124,9 @@ export default function StudioApp() { const saved = state.drafts[START] const local = drafts.current[START] const restoreStart = !startPicturesTouched.current && saved !== undefined && saved.attachments.length > 0 && (local?.attachments.length ?? 0) === 0 + // A saved revision names a draft only where this window has not changed it. + for (const [key, draft] of Object.entries(state.drafts)) if (!(key in drafts.current) && !edits.current[key]) revs.current[key] = draft.rev drafts.current = { ...state.drafts, ...drafts.current } - for (const [key, draft] of Object.entries(state.drafts)) revs.current[key] ??= draft.rev if (restoreStart) drafts.current = { ...drafts.current, [START]: { text: local?.text ?? saved.text, attachments: saved.attachments } } // A project made while this was on its way is newer than the saved list, so it stays. setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) @@ -228,16 +225,30 @@ export default function StudioApp() { } }, []) - /** Saves one screen's draft, and keeps the revision the sidecar gave it. */ + /** Saves one screen's draft, and keeps the revision the sidecar gave it if the draft did not change meanwhile. */ const storeDraft = (key: string, draft: Draft) => { + const at = edits.current[key] ?? 0 const save = sidecar('drafts.set', { key, ...draft }).then(({ rev }) => { - revs.current[key] = Math.max(revs.current[key] ?? 0, rev) + if ((edits.current[key] ?? 0) === at) revs.current[key] = Math.max(revs.current[key] ?? 0, rev) return rev }) saving.current[key] = save.catch(() => undefined) return save } + /** Counts a change to a screen's draft. Its revision no longer names it. */ + const changed = (key: string) => { + edits.current[key] = (edits.current[key] ?? 0) + 1 + delete revs.current[key] + } + + /** Changes a screen's draft from outside its composer, and saves it. */ + const putDraft = (key: string, draft: Draft) => { + changed(key) + drafts.current = { ...drafts.current, [key]: draft } + void storeDraft(key, draft).catch((err: unknown) => setError(String(err))) + } + /** Saves the draft now, if one waits to be saved. */ const saveDraft = async () => { const waiting = unsaved.current @@ -261,10 +272,7 @@ export default function StudioApp() { } /** Counts an edit to the open screen's composer. */ - const edited = () => { - const key = currentId.current ?? START - edits.current[key] = (edits.current[key] ?? 0) + 1 - } + const edited = () => changed(currentId.current ?? START) const type = (value: string) => { edited() @@ -283,9 +291,12 @@ export default function StudioApp() { queueDraft(composerText.current, next) } - /** Keeps pasted pictures in files, then adds them to the screen they were pasted on, if the person is still there. */ + /** + * Keeps pasted pictures in files, then adds them to the screen they were pasted on: to its composer if the person is + * there, or else to its saved draft. + */ const pastePictures = (pasted: PastedPicture[]) => { - const request = loadRequest.current + const key = currentId.current ?? START void Promise.all( pasted.map(async ({ name, file }) => ({ name, @@ -294,8 +305,12 @@ export default function StudioApp() { })), ) .then((kept) => { - if (request !== loadRequest.current) return - setPictures([...unsent.current, ...kept.map(({ name, path, file }) => ({ name, path, previewUrl: URL.createObjectURL(file) }))]) + if ((currentId.current ?? START) === key) { + setPictures([...unsent.current, ...kept.map(({ name, path, file }) => ({ name, path, previewUrl: URL.createObjectURL(file) }))]) + return + } + const draft = drafts.current[key] ?? { text: '', attachments: [] } + putDraft(key, { ...draft, attachments: [...draft.attachments, ...kept.map(({ path }) => path)] }) }) .catch((err: unknown) => setError(String(err))) } @@ -404,45 +419,47 @@ export default function StudioApp() { let sentTo: string | null = null const empty: Draft = { text: '', attachments: [] } try { - // What this send went out with: its screen's edits and the revision of the last save of its draft, both taken - // before anything waits, as the person may go on typing. The start screen's draft may move to the new project, by - // the sidecar's rule that its text is the title. + // The draft this send goes out as, named by its revision: the one the sidecar gave the draft as it is now, or else + // the one a save of it gives now. Both are taken before the person can change it. A save that fails ends the send. const fromKey = current?.id ?? START - const fromEdits = edits.current[fromKey] ?? 0 - const before = revs.current[fromKey] - const saved = saveDraft() - const fromSave = saving.current[fromKey] - await saved - const fromRev = (await fromSave) ?? before + const goingOut: Draft = { text, attachments: attachments.map((a) => a.path) } + const waiting = unsaved.current?.key === fromKey ? unsaved.current : null + if (waiting !== null) { + window.clearTimeout(waiting.timer) + unsaved.current = null + drafts.current = { ...drafts.current, [fromKey]: waiting.draft } + } + const known = waiting === null ? revs.current[fromKey] : undefined + const rev = known ?? (await storeDraft(fromKey, goingOut)) const createdHere = current === null - let moved = false let project = current if (!project) { - const { draft: took, ...created } = await sidecar('projects.create', { title: message || 'New part' }) + // One project per first message: sent again, the same revision finds the project it made. + const { draft: took, ...created } = await sidecar('projects.create', { title: message || 'New part', rev }) project = created - // The project holds the start screen's draft only if the sidecar says it took it. The start screen's own draft, - // if the person changed it since this send, is newer, and stays. + // The project holds the start screen's draft only if the sidecar says it took it, and then the start screen's + // saved draft is gone, unless the person changed it since. A composer still showing it empties once the send + // goes through. if (took !== null) { - moved = true - const { rev, ...draft } = took + const { rev: tookRev, ...draft } = took drafts.current = { ...drafts.current, [created.id]: draft } - revs.current[created.id] = rev - if ((edits.current[START] ?? 0) === fromEdits) drafts.current = { ...drafts.current, [START]: empty } + revs.current[created.id] = tookRev + if (revs.current[START] === tookRev) drafts.current = { ...drafts.current, [START]: empty } } - setProjects((prev) => [created, ...prev]) + setProjects((prev) => (prev.some((p) => p.id === created.id) ? prev : [created, ...prev])) if (loadRequest.current === request) { - // The person stayed on the screen this was sent from, so what they changed there while the project was made, - // even back to the same text, is a newer draft. It follows them to the project, and the start screen is empty - // again. A save still waiting belongs to that screen, so it goes too. + // The person stayed on the screen this was sent from, so its composer goes with them to the project: as + // what went out, or as a newer draft if they changed it while the project was made. A save still waiting + // belongs to that screen, so it goes too. if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) unsaved.current = null - if ((edits.current[START] ?? 0) !== fromEdits) { - const draft: Draft = { text: composerText.current, attachments: unsent.current.map((a) => a.path) } - edits.current[created.id] = (edits.current[created.id] ?? 0) + 1 - drafts.current = { ...drafts.current, [created.id]: draft, [START]: empty } - for (const [key, value] of [[created.id, draft], [START, empty]] as const) { - void storeDraft(key, value).catch((err: unknown) => setError(String(err))) - } + const now: Draft = { text: composerText.current, attachments: unsent.current.map((a) => a.path) } + if (revs.current[START] === rev && took === null) { + drafts.current = { ...drafts.current, [created.id]: now } + revs.current[created.id] = rev + } else if (revs.current[START] !== rev) { + putDraft(created.id, now) + putDraft(START, empty) } show(created, true) request = loadRequest.current @@ -451,67 +468,39 @@ export default function StudioApp() { } const id = project.id sentTo = id - // Edits on the project's screen after this send went out make a newer draft. A project this send made had none - // before it, so text typed while it was made counts as one. - const projectEdits = createdHere ? 0 : fromEdits - // The sidecar removes the project's draft only if it is still the one this send went out with, so a draft the start - // screen did not move to the project, or a newer one, stays. - const draftRev = fromRev const outgoing = attachments - const last = pending.current.get(id) - const sendable = - last !== undefined && last.edits === projectEdits - ? last - : { edits: projectEdits, images: await Promise.all(outgoing.map((a) => studio.importFile(id, a.path))), id: crypto.randomUUID() } - pending.current.set(id, sendable) - await sidecar('chat.send', { - project: id, - requestId: sendable.id, - text: message, - attachments: sendable.images, - ...(draftRev === undefined ? {} : { draftRev }), - }) - pending.current.delete(id) + const images = imported.current.get(rev) ?? (await Promise.all(outgoing.map((a) => studio.importFile(id, a.path)))) + imported.current.set(rev, images) + // The sidecar removes the project's draft only if it is still this one, so a newer draft stays. + await sidecar('chat.send', { project: id, requestId: `draft-${rev}`, text: message, attachments: images, draftRev: rev }) + imported.current.delete(rev) setSendErrors(({ [id]: _, ...rest }) => rest) + // Each screen that still holds what went out is empty again, as its saved draft is. The project's newer draft keeps + // all but the pictures that went out. A newer start screen draft is the person's next idea, and stays as it is. const sent = new Set(outgoing.map((a) => a.path)) - if ((edits.current[id] ?? 0) === projectEdits) { - // Nothing changed on the project's screen since: its composer held what went out, and the sidecar removed the - // draft it went out with. - drafts.current = { ...drafts.current, [id]: empty } - if (currentId.current === id) { - setText('') - unsent.current = [] - setAttachments([]) - } - } else { - // A newer draft, even one that reads the same, is the next message. Only the pictures just sent leave it. - const keep = (draft: Draft): Draft => ({ ...draft, attachments: draft.attachments.filter((path) => !sent.has(path)) }) - if (unsaved.current?.key === id) unsaved.current.draft = keep(unsaved.current.draft) - const cached = drafts.current[id] - if (cached !== undefined && cached.attachments.some((path) => sent.has(path))) { - drafts.current = { ...drafts.current, [id]: keep(cached) } - if (unsaved.current?.key !== id) { - void storeDraft(id, keep(cached)).catch((err: unknown) => setError(String(err))) + for (const key of createdHere ? [id, START] : [id]) { + if (revs.current[key] !== rev && key === START) continue + if (revs.current[key] === rev) { + drafts.current = { ...drafts.current, [key]: empty } + delete revs.current[key] + // The start screen's draft stays saved when the sidecar did not take it, as a first message of only pictures. + if (key === START) void storeDraft(START, empty).catch((err: unknown) => setError(String(err))) + if ((currentId.current ?? START) === key) { + setText('') + unsent.current = [] + setAttachments([]) } + continue } - if (currentId.current === id) { + const keep = (draft: Draft): Draft => ({ ...draft, attachments: draft.attachments.filter((path) => !sent.has(path)) }) + if (unsaved.current?.key === key) unsaved.current.draft = keep(unsaved.current.draft) + const cached = drafts.current[key] + if (cached !== undefined && cached.attachments.some((path) => sent.has(path)) && unsaved.current?.key !== key) putDraft(key, keep(cached)) + if ((currentId.current ?? START) === key) { unsent.current = unsent.current.filter((a) => !sent.has(a.path)) setAttachments(unsent.current) } } - // A first message the start screen still holds, as one of only pictures, which the sidecar does not move, goes - // from it too, unless the person started something new there. - if (createdHere && !moved && (edits.current[START] ?? 0) === fromEdits) { - drafts.current = { ...drafts.current, [START]: empty } - if (unsaved.current?.key !== START) { - void storeDraft(START, empty).catch((err: unknown) => setError(String(err))) - } - if (currentId.current === null) { - setText('') - unsent.current = [] - setAttachments([]) - } - } outgoing.forEach(releaseAttachment) } catch (err) { const message = String(err) From cb45257bddf1faeccb598e63efdda5b0bec9d5bf Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 17:36:04 -0600 Subject: [PATCH 52/59] fix(studio): the sidecar owns the draft and the send, and a send names only a stored revision The window and the sidecar both owned the draft and the outgoing message, so every window-side identity model had to guess whether its copy matched the stored draft (#205). - chat.send takes { key, rev } and answers { project }. From the start screen it makes the project for that revision, or finds the one it made, and moves the start draft by revision. A message already committed as draft- is not sent again. The draft must still be that revision. Pictures are copied into refs/draft-/ once per revision. The message and the draft removal commit together under the update pass, as before. - projects.create is gone: nothing but a send made projects. - The window keeps per screen only its draft, its last completed save and that save's revision, the save on its way, and the send on its way. One save per screen at a time, no save of a screen while its send is on its way, and a send saves first only when the screen changed. No request ids, no picture imports, no guessing what the sidecar moved. - The 12 cases from #205 are sidecar and window tests. The window mock sends by revision as the sidecar does. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 2 +- sidecar/src/chat.ts | 81 +- sidecar/src/methods.ts | 8 +- sidecar/src/protocol.ts | 19 +- sidecar/src/store.ts | 54 +- sidecar/test/agent.test.ts | 52 +- sidecar/test/fixtures/scripted-sidecar.ts | 7 +- sidecar/test/send.test.ts | 161 ++++ sidecar/test/sidecar.ts | 11 +- sidecar/test/store.test.ts | 74 +- sidecar/test/switch.test.ts | 27 +- sidecar/test/tools.test.ts | 84 +- sidecar/test/waiting.test.ts | 32 +- src/studio/StudioApp.test.tsx | 826 ++++++++---------- src/studio/StudioApp.tsx | 410 ++++----- 15 files changed, 929 insertions(+), 919 deletions(-) create mode 100644 sidecar/test/send.test.ts diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index d165d604..3770e4a0 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts -fccd0d9fb5629626d0ada99746dfb3805381238eadfeedb7c6f375a491123828 sidecar/src/chat.ts +b97db6f1b7c07228dd20289e92f3acdbc11d34c0441ea7894c62908fb6a458ec sidecar/src/chat.ts 6d4eb78b893da94d0e56e02ea465b790981ae7d64fcce361d15ea42fea3c17d4 sidecar/src/app.ts diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index bb098758..e71ed3be 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -3,9 +3,9 @@ * removal of the draft that holds the message together. A turn that stops before its answer waits with the fix named, * and continues once it is fixed. The window sees committed entries only. */ -import { mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs' +import { constants, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' -import { extname, isAbsolute, join, relative } from 'node:path' +import { basename, extname, join, relative } from 'node:path' import type { Context } from '@earendil-works/chord' import { awaitWithContext, BACKGROUND_CONTEXT } from '@earendil-works/chord/context' import type { ImageContent, Models, TextContent } from '@earendil-works/pi-ai' @@ -24,9 +24,9 @@ import { import type { ConversationView } from '@earendil-works/pi-durable' import { leaves, WORKFLOW, WORKFLOW_VERSION } from './agent' import type { Busy } from './busy' -import type { ChatEntry, ChatView, SidecarEvent, Turn, Waiting } from './protocol' +import { type ChatEntry, type ChatView, type Project, type SidecarEvent, START, type Turn, type Waiting } from './protocol' import { isLoginFailure } from './inference' -import { DraftsDoc, LoginSavesDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' +import { DRAFT_CHANGED, DraftsDoc, LoginSavesDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' import { chatEntry } from './transcript' const context: Context = BACKGROUND_CONTEXT @@ -83,6 +83,45 @@ function userContent(folder: string, text: string, attachments: string[]): strin return [{ type: 'text', text: `${text}${note}` }, ...images] } +/** + * Copies a draft's pictures into the project, as `refs/draft-/`, and answers those paths. Each copy is + * written beside its place and then renamed into it, so a send of the same revision again, as after a crash or a + * refused send, finds every finished copy and makes no second one. + */ +function copyPictures(folder: string, rev: number, sources: string[]): string[] { + if (sources.length === 0) return [] + const rel = join('refs', `draft-${rev}`) + const dir = join(folder, rel) + // The agent could have made either folder a link. Pictures land inside the project or nowhere, so each folder is + // checked before anything is made in it. + for (const path of [join(folder, 'refs'), dir]) { + mkdirSync(path, { recursive: true }) + if (leaves(relative(realpathSync.native(folder), realpathSync.native(path)))) throw new Error('refs/ leads outside the project folder') + } + const names = new Set() + return sources.map((source) => { + const clean = basename(source).replace(/[^A-Za-z0-9._-]/g, '-') + const file = clean.replace(/\./g, '') === '' ? 'picture.png' : clean + const dot = file.lastIndexOf('.') + const [stem, ext] = dot > 0 ? [file.slice(0, dot), file.slice(dot)] : [file, ''] + let name = file + for (let n = 2; names.has(name); n++) name = `${stem}-${n}${ext}` + names.add(name) + const target = join(dir, name) + if (lstatSync(target, { throwIfNoEntry: false }) === undefined) { + // A picture that is gone can't be sent. Nor can a file that is not regular, such as a pipe, which would hold the copy. + if (statSync(source, { throwIfNoEntry: false })?.isFile() !== true) { + throw new Error(`A picture in this message can't be read: ${basename(source)}. Take it out and send again.`) + } + const part = join(dir, `.${name}.part`) + rmSync(part, { force: true }) + copyFileSync(source, part, constants.COPYFILE_EXCL) + renameSync(part, target) + } + return join(rel, name) + }) +} + export class Chats { readonly #o: ChatsOptions readonly #watches = new Map>() @@ -203,19 +242,26 @@ export class Chats { } /** - * Sends a message. The message, the settings its turn runs with, and the removal of the draft it went out with - * (`draftRev`) commit together. A send whose `requestId` was already committed changes nothing, so the window can send again after any - * failure, even once a picture it sent is gone. + * Sends the draft saved as revision `rev` under `key`, a project id or `START`, and answers the project it went to. + * Each step is safe to run again after a crash or a lost answer: the project a start screen revision made is found, + * not made again, a message already committed as `draft-` is not sent again, and the pictures are copied once. + * The message, the settings its turn runs with, and the removal of the draft commit together. Only that revision's + * content goes out. A draft saved after it is the next message and stays, even one that reads the same. */ - async send(project: string, requestId: string, text: string, attachments: string[], draftRev: number | undefined): Promise { + async send(key: string, rev: number): Promise { const { store, extension, tools, version, busy } = this.#o - const conversation = await this.#conversation(project) + const project = key === START ? await store.startProject(rev) : await store.project(key) + const conversation = await this.#conversation(project.id) + const requestId = `draft-${rev}` if ((await store.harness.commit((tx) => tx.submissionByRequest(conversation.id, requestId), context)) !== undefined) { - await this.#watch(project, conversation) - return + await this.#watch(project.id, conversation) + return project } - const folder = store.projectFolder(project) - const content = userContent(folder, text, attachments) + const draft = await store.draft(project.id) + if (draft?.rev !== rev) throw new Error(DRAFT_CHANGED) + const folder = store.projectFolder(project.id) + const attachments = copyPictures(folder, rev, draft.attachments) + const content = userContent(folder, draft.text, attachments) try { await busy.during(() => store.harness.commit(async (tx) => { if ((await tx.submissionByRequest(conversation.id, requestId)) !== undefined) return @@ -227,15 +273,13 @@ export class Chats { content, requestId, whenBusy: 'reject', - entry: { kind: 'm3d.user', data: { text, images: attachments } }, + entry: { kind: 'm3d.user', data: { text: draft.text, images: attachments } }, }) await configure(tx, conversation.id, { model, thinkingLevel: thinking, extensions: [extension], tools, instructions: WORKFLOW, cwd: folder }) const turn: Turn = { requestId, model, thinking, tools: tools.map((t) => t.name), instructions: WORKFLOW_VERSION, app: version, startedMs: Date.now() } ;(await tx.doc(TurnDoc, conversation.id)).turn = turn - // Only the draft this message went out with goes. One saved after it is the next message, even one that reads - // the same, so it stays. const { drafts } = await tx.doc(DraftsDoc, store.root.id) - if (draftRev !== undefined && drafts[project]?.rev === draftRev) delete drafts[project] + if (drafts[project.id]?.rev === rev) delete drafts[project.id] const waiting = await tx.doc(WaitingDoc, conversation.id) waiting.waiting = null waiting.tool = false @@ -245,7 +289,8 @@ export class Chats { if (error instanceof ConversationBusy) throw new Error('The agent is still working on the last message.') throw error } - await this.#watch(project, conversation) + await this.#watch(project.id, conversation) + return project } /** diff --git a/sidecar/src/methods.ts b/sidecar/src/methods.ts index 316b7218..a2ef3455 100644 --- a/sidecar/src/methods.ts +++ b/sidecar/src/methods.ts @@ -79,7 +79,6 @@ export function handlers({ store, logins, chats, version, legacy, pictures }: Se params: compile(obj({ key: str(), text: str(), attachments: arr(str()) })), run: async ({ key, text, attachments }) => ({ rev: await store.setDraft(key, { text, attachments }) }), }, - 'projects.create': { params: compile(obj({ title: str(), rev: opt(int()) })), run: ({ title, rev }) => store.createProject(title, rev) }, 'auth.status': { params: compile(obj({})), run: () => logins.status() }, 'auth.setKey': { params: compile(obj({ provider, key: str({ minLength: 1 }), baseUrl: opt(str({ minLength: 1 })) })), @@ -111,11 +110,8 @@ export function handlers({ store, logins, chats, version, legacy, pictures }: Se }, 'chat.open': { params: compile(obj({ project: str() })), run: ({ project }) => chats.open(project) }, 'chat.send': { - params: compile(obj({ project: str(), requestId: str({ minLength: 1 }), text: str(), attachments: arr(str()), draftRev: opt(int()) })), - run: async ({ project, requestId, text, attachments, draftRev }) => { - await chats.send(project, requestId, text, attachments, draftRev) - return null - }, + params: compile(obj({ key: str({ minLength: 1 }), rev: int() })), + run: async ({ key, rev }) => ({ project: await chats.send(key, rev) }), }, 'chat.stop': { params: compile(obj({ project: str() })), diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index cf8c6407..901833df 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -21,10 +21,14 @@ export type Settings = { /** `conversation` is the project's Pi conversation id. */ export type Project = { id: string; conversation: number; title: string; createdMs: number } -/** A project just made, and the start screen's draft it took as its own, if it took one. */ -export type CreatedProject = Project & { draft: SavedDraft | null } -/** An unsent message, by project id, or `new` for the start screen. Attachments are paths inside the project. */ +/** The draft key of the start screen, whose message has no project yet. Every other draft is keyed by its project id. */ +export const START = 'new' + +/** + * An unsent message, by project id, or `START` for the start screen. Attachments are the paths of its pictures where + * they are now: a dropped file, or a pasted picture the sidecar keeps. A send copies them into its project. + */ export type Draft = { text: string; attachments: string[] } /** @@ -123,7 +127,6 @@ export interface Methods { 'setup.check': { params: Record; result: Need[] } /** Imports the chats from before the store again, as after the person let the app read the projects folder. */ 'legacy.import': { params: Record; result: { imported: number; blocked: string | null } } - 'projects.create': { params: { title: string; rev?: number }; result: CreatedProject } 'auth.status': { params: Record; result: Login[] } /** A key, or for Anthropic a proxy's base URL and token. */ 'auth.setKey': { params: { provider: ProviderId; key: string; baseUrl?: string }; result: Login[] } @@ -131,12 +134,12 @@ export interface Methods { 'auth.answer': { params: { provider: ProviderId; text: string }; result: null } 'auth.logout': { params: { provider: ProviderId }; result: Login[] } 'chat.open': { params: { project: string }; result: ChatView } - /** `requestId` is the window's own id for this message: sending it again never sends twice. */ /** - * Sends a message. `draftRev` names the saved draft it went out with, which the send removes in the same commit. A - * draft saved after it, newer, stays. + * Sends the draft saved as revision `rev` under `key`, and answers the project it went to. From the start screen it + * makes that project first. Sent again with the same revision, as after a lost answer, it sends nothing more and + * answers the same project. */ - 'chat.send': { params: { project: string; requestId: string; text: string; attachments: string[]; draftRev?: number }; result: null } + 'chat.send': { params: { key: string; rev: number }; result: { project: Project } } 'chat.stop': { params: { project: string }; result: null } 'chat.retry': { params: { project: string }; result: null } /** The tools a project's agent is offered, as Pi resolves them. */ diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index b57baf12..147c929a 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -13,7 +13,7 @@ import { type Conversation, defineDoc, type HarnessOptions, Harness, type Regist import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node' import type { LoginIndex } from './inference' import type { LegacyProject } from './legacy' -import type { CreatedProject, Draft, LoginKind, Project, SavedDraft, Settings, Setup, StoredState, Turn, Waiting } from './protocol' +import { type Draft, type LoginKind, type Project, type SavedDraft, type Settings, type Setup, START, type StoredState, type Turn, type Waiting } from './protocol' const context = BACKGROUND_CONTEXT @@ -47,7 +47,7 @@ export const SettingsDoc = defineDoc({ checkpointWhen: always, }) -/** The projects, and the project each first message's draft revision made, so a send of it again makes no other. */ +/** The projects, and the project each start screen draft revision made, so a send of it again makes no other. */ const ProjectsDoc = defineDoc<{ projects: Project[]; madeFor?: Record }>({ kind: 'm3d.projects', version: 1, @@ -120,6 +120,9 @@ const LoginsDoc = defineDoc<{ logins: Record }>({ /** The store's SQLite storage, through Pi's own async adapter, which queues every operation and transaction. */ export const openStorage = (path: string) => openNodeSqliteStorage(path) +/** Why a send names a draft revision the store no longer holds: the person changed the draft since that save. */ +export const DRAFT_CHANGED = 'The draft changed before it went out. Send it again.' + /** Another process owns the store. */ export class StoreInUse extends Error {} @@ -238,42 +241,45 @@ export class Store implements LoginIndex { } /** - * Makes a project: its conversation, its place in the index, and the start screen's draft moved to it when that - * draft holds the project's first message, in one commit. A start screen draft with other text is the person's next - * idea, so it stays. The folder is made inside that commit, before anything is kept, so a folder that cannot be made - * keeps nothing. A crash after the folder leaves an empty folder, which the next id skips. It answers with the draft it - * moved, so the window shows what the project holds rather than guessing. A first message's draft revision makes one - * project only: asked again for it, as after an answer that was lost, it answers with that project. + * The project the start screen's draft saved as `rev` goes to. The first time, it makes the project, titled by the + * draft's first line, and moves that draft to it, in one commit. Only the draft saved as `rev` moves, so a newer + * start screen draft is never sent in its place. The folder is made inside that commit, before anything is kept, so a + * folder that cannot be made keeps nothing. A crash after the folder leaves an empty folder, which the next id skips. + * Asked again for the same revision, as after a lost answer, it answers the project that revision made and changes + * nothing. */ - async createProject(title: string, rev?: number): Promise { - const firstLine = (title.split('\n')[0] ?? '').trim().slice(0, 80) || 'New part' + async startProject(rev: number): Promise { const now = new Date() // The person's own date, as the folder name shows it. const today = new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10) - const base = `${today}-${slug(firstLine)}` - const project = await this.harness.commit(async (tx) => { + return this.harness.commit(async (tx) => { const index = await tx.doc(ProjectsDoc, this.root.id) - const before = rev === undefined ? undefined : index.madeFor?.[rev] - const again = index.projects.find((p) => p.id === before) - if (again !== undefined) return { ...again, draft: null } + const made = index.projects.find((p) => p.id === index.madeFor?.[rev]) + // A plain copy, since the documents a commit hands out are only valid inside it. + if (made !== undefined) return { ...made } + const { drafts } = await tx.doc(DraftsDoc, this.root.id) + const draft = drafts[START] + if (draft?.rev !== rev) throw new Error(DRAFT_CHANGED) + const title = (draft.text.split('\n')[0] ?? '').trim().slice(0, 80) || 'New part' + const base = `${today}-${slug(title)}` const taken = new Set(index.projects.map((p) => p.id)) let id = base // A folder already on disk, which the store may not list, is never taken over. for (let n = 2; taken.has(id) || existsSync(join(this.projectsDir, id)); n++) id = `${base}-${n}` this.projectFolder(id) const conversation = await tx.createConversation({ ownership: { kind: 'ownerless' } }) - const created: Project = { id, conversation: conversation.id, title: firstLine, createdMs: now.getTime() } + const created: Project = { id, conversation: conversation.id, title, createdMs: now.getTime() } index.projects.push(created) - if (rev !== undefined) index.madeFor = { ...index.madeFor, [rev]: id } - const { drafts } = await tx.doc(DraftsDoc, this.root.id) - const draft = drafts.new - if (draft === undefined || draft.text.trim() !== title.trim()) return { ...created, draft: null } + index.madeFor = { ...index.madeFor, [rev]: id } drafts[id] = draft - delete drafts.new - // A plain copy, since the documents a commit hands out are only valid inside it. - return { ...created, draft: { text: draft.text, attachments: [...draft.attachments], rev: draft.rev } } + delete drafts[START] + return { ...created } }, context) - return project + } + + /** The draft saved under `key`, a project id or `START`. */ + async draft(key: string): Promise { + return (await this.harness.snapshot(DraftsDoc, this.root.id, context))?.drafts[key] } async projects(): Promise { diff --git a/sidecar/test/agent.test.ts b/sidecar/test/agent.test.ts index a3392c21..c69a87ba 100644 --- a/sidecar/test/agent.test.ts +++ b/sidecar/test/agent.test.ts @@ -21,11 +21,10 @@ function start(where: ReturnType, script: string): App { return spawnSidecar(where.data, where.projects, new Map(), { script, env: where.env }) } -async function newProject(app: App, modelId = 'faux-a'): Promise { - await app.call('settings.set', { model: { provider: 'faux', modelId } }) - const { id } = (await app.call('projects.create', { title: 'A 20 mm cube' })) as { id: string } - await app.call('chat.open', { project: id }) - return id +/** Sends a first message from the start screen, which makes its project, and answers that project. */ +async function firstSend(app: App, text: string): Promise { + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + return (await app.sendDraft('new', text)).project } const hasAnswer = (entries: ChatEntry[], text: string) => entries.some((entry) => entry.kind === 'assistant' && entry.text === text) @@ -45,8 +44,6 @@ async function waitForFile(path: string, ms = 15_000) { } } -const send = (app: App, project: string, requestId: string, text: string, draftRev?: number) => - app.call('chat.send', { project, requestId, text, attachments: [], ...(draftRev === undefined ? {} : { draftRev }) }) const saveDraft = async (app: App, key: string, text: string) => ((await app.call('drafts.set', { key, text, attachments: [] })) as { rev: number }).rev @@ -55,15 +52,13 @@ describe('a send', () => { const where = scratch() const app = start(where, 'models') await app.next() - const project = await newProject(app) - const rev = await saveDraft(app, project, 'A 20 mm cube') - - await send(app, project, 'r1', 'A 20 mm cube', rev) + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { project, rev } = await app.sendDraft('new', 'A 20 mm cube') expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({}) const view = (await app.call('chat.open', { project })) as ChatView expect(view.entries[0]).toMatchObject({ kind: 'user', text: 'A 20 mm cube' }) expect(view.turn).toMatchObject({ - requestId: 'r1', + requestId: `draft-${rev}`, model: { provider: 'faux', modelId: 'faux-a' }, thinking: 'high', tools: ['read', 'write', 'edit', 'run', 'slice', 'astra'], @@ -71,15 +66,15 @@ describe('a send', () => { }) await answered(app, project, 'answered by faux-a') - await send(app, project, 'r1', 'A 20 mm cube') + await app.call('chat.send', { key: project, rev }) const again = (await app.call('chat.open', { project })) as ChatView expect(again.entries.filter((entry) => entry.kind === 'user')).toHaveLength(1) // A send while a turn runs is refused whole: no message, and the draft stays. - await send(app, project, 'r2', 'please wait') + await app.sendDraft(project, 'please wait') await waitForFile(join(where.projects, project, 'started')) const red = await saveDraft(app, project, 'make it red') - await expect(send(app, project, 'r3', 'make it red', red)).rejects.toThrow('The agent is still working on the last message.') + await expect(app.call('chat.send', { key: project, rev: red })).rejects.toThrow('The agent is still working on the last message.') expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: [], rev: red }) const refused = (await app.call('chat.open', { project })) as ChatView expect(refused.entries.filter((entry) => entry.kind === 'user').map((entry) => entry.kind === 'user' && entry.text)).toEqual(['A 20 mm cube', 'please wait']) @@ -87,19 +82,6 @@ describe('a send', () => { await answered(app, project, 'answered by faux-a') await app.quit() }) - - test('leaves a draft saved after the one it went out with, even one that reads the same', async () => { - const where = scratch() - const app = start(where, 'models') - await app.next() - const project = await newProject(app) - const sent = await saveDraft(app, project, 'make it red') - const newer = ((await app.call('drafts.set', { key: project, text: 'make it red', attachments: ['/me/new.jpg'] })) as { rev: number }).rev - expect(newer).toBeGreaterThan(sent) - await send(app, project, 'r1', 'make it red', sent) - expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: ['/me/new.jpg'], rev: newer }) - await app.quit() - }) }) describe('a restart', () => { @@ -107,8 +89,7 @@ describe('a restart', () => { const where = scratch() const first = start(where, 'models') await first.next() - const project = await newProject(first) - await send(first, project, 'r1', 'hello') + const project = await firstSend(first, 'hello') await first.until((event) => event.type === 'chat' && event.project === project && event.running === false && (event.entries as ChatEntry[]).some((e) => e.kind === 'assistant'), 20_000) first.child.kill('SIGKILL') await first.child.exited @@ -125,8 +106,7 @@ describe('a crash mid-turn', () => { const where = scratch() const first = start(where, 'ops') await first.next() - const project = await newProject(first) - await send(first, project, 'r1', 'Build it') + const project = await firstSend(first, 'Build it') const log = join(where.projects, project, 'ops.log') await waitForFile(log) while (!readFileSync(log, 'utf8').includes('op2')) await Bun.sleep(25) @@ -160,15 +140,14 @@ describe('settings', () => { const where = scratch() const app = start(where, 'models') await app.next() - const project = await newProject(app, 'faux-a') - await send(app, project, 'r1', 'please wait') + const project = await firstSend(app, 'please wait') await waitForFile(join(where.projects, project, 'started')) await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-b' } }) writeFileSync(join(where.projects, project, 'go'), '') await answered(app, project, 'answered by faux-a') expect(((await app.call('chat.open', { project })) as ChatView).turn?.model.modelId).toBe('faux-a') - await send(app, project, 'r2', 'and now?') + await app.sendDraft(project, 'and now?') await answered(app, project, 'answered by faux-b') expect(((await app.call('chat.open', { project })) as ChatView).turn?.model.modelId).toBe('faux-b') await app.quit() @@ -180,9 +159,8 @@ describe('the agent', () => { const where = scratch() const app = start(where, 'escape') await app.next() - const project = await newProject(app) + const project = await firstSend(app, 'read the hostname') expect(await app.call('agent.tools', { project })).toEqual(['read', 'write', 'edit', 'run', 'slice', 'astra']) - await send(app, project, 'r1', 'read the hostname') await answered(app, project, 'tried') const view = (await app.call('chat.open', { project })) as ChatView const read = view.entries.find((entry) => entry.kind === 'tool') diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index d6ea1191..c6f8d237 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -110,7 +110,12 @@ await runSidecar({ argv: process.argv.slice(2), addProviders: (models) => { const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-a' }, { id: 'faux-b' }] }) - faux.setResponses(Array.from({ length: 200 }, () => (context, _options, _state, model) => script(context.messages, model))) + // Every script answers `make the project` with `made`, so a test can make a project before its first real message. + faux.setResponses( + Array.from({ length: 200 }, () => (context, _options, _state, model) => + sinceUser(context.messages).text === 'make the project' ? fauxAssistantMessage(fauxText('made')) : script(context.messages, model), + ), + ) models.setProvider(faux.provider) const astra = fauxProvider({ provider: 'openai-codex', models: [{ id: 'gpt-6-astra' }] }) // Astra runs a command of its own first, in the project folder it shares. The provider refuses the key `refused`, diff --git a/sidecar/test/send.test.ts b/sidecar/test/send.test.ts new file mode 100644 index 00000000..63f4f737 --- /dev/null +++ b/sidecar/test/send.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from 'bun:test' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChatEntry, ChatView, SavedDraft, StoredState } from '../src/protocol' +import { spawnSidecar } from './sidecar' + +type App = ReturnType + +function scratch() { + const dir = mkdtempSync(join(tmpdir(), 'm3d-send-')) + const bin = join(dir, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'uv'), '#!/bin/sh\n[ "$1" = run ] && shift\nexec "$@"\n') + chmodSync(join(bin, 'uv'), 0o755) + return { dir, data: join(dir, 'state'), projects: join(dir, 'projects'), env: { PATH: `${bin}:${process.env.PATH}` } } +} + +async function start(where: ReturnType): Promise { + const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) + await app.next() + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + return app +} + +const state = async (app: App) => (await app.call('state.get')) as StoredState +const drafts = async (app: App): Promise> => (await state(app)).drafts +const save = async (app: App, key: string, text: string, attachments: string[] = []) => + ((await app.call('drafts.set', { key, text, attachments })) as { rev: number }).rev +const send = async (app: App, key: string, rev: number) => ((await app.call('chat.send', { key, rev })) as { project: { id: string; title: string } }).project +const asked = async (app: App, project: string) => + ((await app.call('chat.open', { project })) as ChatView).entries.flatMap((entry) => (entry.kind === 'user' ? [entry] : [])) + +/** Waits until the chat holds this answer, already committed or in a later change. */ +async function answered(app: App, project: string, text: string) { + const has = (entries: ChatEntry[]) => entries.some((entry) => entry.kind === 'assistant' && entry.text === text) + if (has(((await app.call('chat.open', { project })) as ChatView).entries)) return + await app.until((event) => event.type === 'chat' && event.project === project && has(event.entries as ChatEntry[]), 30_000) +} + +describe('a send from the start screen', () => { + test('makes the project, sends the draft, and leaves no draft behind', async () => { + const where = scratch() + const app = await start(where) + const rev = await save(app, 'new', 'A knob\nfor a 6 mm shaft') + const project = await send(app, 'new', rev) + expect(project.title).toBe('A knob') + expect(await drafts(app)).toEqual({}) + expect((await asked(app, project.id)).map((entry) => entry.text)).toEqual(['A knob\nfor a 6 mm shaft']) + await app.quit() + }) + + test('moves only the draft it went out as: a newer start draft is never sent in its place, and stays', async () => { + const where = scratch() + const app = await start(where) + const older = await save(app, 'new', 'A knob') + const newer = await save(app, 'new', 'A dial') + await expect(send(app, 'new', older)).rejects.toThrow('The draft changed before it went out. Send it again.') + expect(await state(app)).toMatchObject({ projects: [], drafts: { new: { text: 'A dial', rev: newer } } }) + + const project = await send(app, 'new', newer) + const latest = await save(app, 'new', 'A shelf bracket') + // Sent again, as after a lost answer, it changes nothing: the newer start draft stays where it is. + expect(await send(app, 'new', newer)).toEqual(project) + expect(await drafts(app)).toEqual({ new: { text: 'A shelf bracket', attachments: [], rev: latest } }) + expect((await asked(app, project.id)).map((entry) => entry.text)).toEqual(['A dial']) + await app.quit() + }) + + test("sent again after a lost answer never touches the project's newer draft", async () => { + const where = scratch() + const app = await start(where) + const rev = await save(app, 'new', 'A knob') + const project = await send(app, 'new', rev) + await answered(app, project.id, 'answered by faux-a') + const newer = await save(app, project.id, 'Make it thinner') + expect(await send(app, 'new', rev)).toEqual(project) + expect(await drafts(app)).toEqual({ [project.id]: { text: 'Make it thinner', attachments: [], rev: newer } }) + expect(await asked(app, project.id)).toHaveLength(1) + expect((await state(app)).projects).toHaveLength(1) + await app.quit() + }) + + test('of only a picture goes to one project once, even sent again after the picture is gone', async () => { + const where = scratch() + const app = await start(where) + const photo = join(where.dir, 'photo.jpg') + writeFileSync(photo, 'jpg') + const rev = await save(app, 'new', '', [photo]) + const project = await send(app, 'new', rev) + expect(project.title).toBe('New part') + rmSync(photo) + expect(await send(app, 'new', rev)).toEqual(project) + expect((await state(app)).projects).toHaveLength(1) + const sent = await asked(app, project.id) + expect(sent).toHaveLength(1) + expect(sent[0]?.images).toEqual([`refs/draft-${rev}/photo.jpg`]) + expect(readdirSync(join(where.projects, project.id, 'refs', `draft-${rev}`))).toEqual(['photo.jpg']) + await app.quit() + }) + + test('of only a picture leaves the start screen empty, after a relaunch too', async () => { + const where = scratch() + const first = await start(where) + const photo = join(where.dir, 'photo.jpg') + writeFileSync(photo, 'jpg') + const rev = await save(first, 'new', '', [photo]) + const project = await send(first, 'new', rev) + expect(await drafts(first)).toEqual({}) + await first.quit() + + const second = await start(where) + expect(await state(second)).toMatchObject({ projects: [{ id: project.id }], drafts: {} }) + await second.quit() + }) +}) + +describe('a send from a project', () => { + test('of a revision that is no longer the draft sends nothing', async () => { + const where = scratch() + const app = await start(where) + const { project } = await app.sendDraft('new', 'A knob') + await answered(app, project, 'answered by faux-a') + const older = await save(app, project, 'Make it red') + const newer = await save(app, project, 'Make it blue') + await expect(send(app, project, older)).rejects.toThrow('The draft changed before it went out. Send it again.') + expect(await asked(app, project)).toHaveLength(1) + expect((await drafts(app))[project]).toEqual({ text: 'Make it blue', attachments: [], rev: newer }) + await app.quit() + }) + + test('with a picture that is gone fails and keeps the draft', async () => { + const where = scratch() + const app = await start(where) + const { project } = await app.sendDraft('new', 'A knob') + await answered(app, project, 'answered by faux-a') + const rev = await save(app, project, 'Like this', [join(where.dir, 'gone.jpg')]) + await expect(send(app, project, rev)).rejects.toThrow("A picture in this message can't be read: gone.jpg. Take it out and send again.") + expect(await asked(app, project)).toHaveLength(1) + expect((await drafts(app))[project]?.rev).toBe(rev) + await app.quit() + }) + + test('copies its pictures once per revision, even when the first try was refused after the copy', async () => { + const where = scratch() + const app = await start(where) + const { project } = await app.sendDraft('new', 'please wait') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) + const photo = join(where.dir, 'photo.jpg') + writeFileSync(photo, 'jpg') + const rev = await save(app, project, 'Like this', [photo, join(where.dir, 'photo.jpg')]) + await expect(send(app, project, rev)).rejects.toThrow('The agent is still working on the last message.') + writeFileSync(join(where.projects, project, 'go'), '') + await answered(app, project, 'answered by faux-a') + await send(app, project, rev) + expect(readdirSync(join(where.projects, project, 'refs', `draft-${rev}`)).sort()).toEqual(['photo-2.jpg', 'photo.jpg']) + expect((await asked(app, project))[1]?.images).toEqual([`refs/draft-${rev}/photo.jpg`, `refs/draft-${rev}/photo-2.jpg`]) + expect((await drafts(app))[project]).toBeUndefined() + await app.quit() + }, 30_000) +}) diff --git a/sidecar/test/sidecar.ts b/sidecar/test/sidecar.ts index 4fe8d04b..b7e4f8c1 100644 --- a/sidecar/test/sidecar.ts +++ b/sidecar/test/sidecar.ts @@ -108,6 +108,15 @@ export function spawnSidecar( return message.value } } + /** + * Saves a screen's draft and sends it, as the window does: `key` is a project id, or `new` for the start screen. + * Answers the project it went to and the draft revision it went out as. + */ + const sendDraft = async (key: string, text: string, attachments: string[] = []) => { + const { rev } = (await call('drafts.set', { key, text, attachments })) as { rev: number } + const { project } = (await call('chat.send', { key, rev })) as { project: { id: string } } + return { project: project.id, rev } + } /** Reads events until one matches, or fails after `ms`. */ const until = async (match: (event: Message) => boolean, ms = 15_000) => { const deadline = Date.now() + ms @@ -128,5 +137,5 @@ export function spawnSidecar( await stderrDone return errors } - return { child, next, send, call, until, quit, stderr, gate } + return { child, next, send, call, sendDraft, until, quit, stderr, gate } } diff --git a/sidecar/test/store.test.ts b/sidecar/test/store.test.ts index 03300272..6aefab1a 100644 --- a/sidecar/test/store.test.ts +++ b/sidecar/test/store.test.ts @@ -50,17 +50,16 @@ describe('app state', () => { const { data, projects } = scratch() const app = spawnSidecar(data, projects) await app.next() - await app.call('drafts.set', { key: 'new', text: 'A wall mount for my dock', attachments: [] }) await app.call('setup.set', { step: 'printer', done: false }) await app.call('settings.set', { model: { provider: 'openai-codex', modelId: 'gpt-6-astra' } }) - const project = (await app.call('projects.create', { title: 'A wall mount for my dock\nfrom a photo' })) as { id: string } - await app.call('drafts.set', { key: project.id, text: 'Make it 2 mm thicker', attachments: ['refs/dock.jpg'] }) + const project = { id: (await app.sendDraft('new', 'A wall mount for my dock\nfrom a photo')).project } + await app.call('drafts.set', { key: project.id, text: 'Make it 2 mm thicker', attachments: ['/Users/me/dock.jpg'] }) const before = await app.call('state.get') expect(before).toMatchObject({ setup: { step: 'printer', done: false }, settings: { model: { provider: 'openai-codex', modelId: 'gpt-6-astra' }, thinking: 'high' }, projects: [{ id: project.id, title: 'A wall mount for my dock' }], - drafts: { [project.id]: { text: 'Make it 2 mm thicker', attachments: ['refs/dock.jpg'] } }, + drafts: { [project.id]: { text: 'Make it 2 mm thicker', attachments: ['/Users/me/dock.jpg'] } }, }) expect(existsSync(join(projects, project.id))).toBe(true) expect(await app.quit()).toBe(EXIT.done) @@ -83,22 +82,24 @@ describe('app state', () => { const app = spawnSidecar(data, projects) await app.next() for (let round = 0; round < 20; round++) { - await app.call('drafts.set', { key: 'new', text: `Knob ${round}`, attachments: [] }) - // The read goes out right behind the commit that moves the draft, without waiting for it. - app.send({ kind: 'call', id: 1000 + round, method: 'projects.create', params: { title: `Knob ${round}` } }) + const { rev } = (await app.call('drafts.set', { key: 'new', text: `Knob ${round}`, attachments: [] })) as { rev: number } + // The read goes out right behind the send, whose first commit makes the project and moves the draft, without + // waiting for it. + app.send({ kind: 'call', id: 1000 + round, method: 'chat.send', params: { key: 'new', rev } }) app.send({ kind: 'call', id: 2000 + round, method: 'state.get', params: {} }) const replies = new Map() while (replies.size < 2) { const message = await app.next() if (message?.kind === 'reply') replies.set(message.id as number, message.value) } - const { id } = replies.get(1000 + round) as { id: string } + const { id } = (replies.get(1000 + round) as { project: { id: string } }).project const state = replies.get(2000 + round) as { projects: { id: string }[]; drafts: Record } const made = state.projects.some((p) => p.id === id) - // Before the commit: no project, the draft on the start screen. After it: the project holds the draft. - expect({ made, onProject: state.drafts[id]?.text, onStart: state.drafts.new?.text }).toEqual( - made ? { made, onProject: `Knob ${round}`, onStart: undefined } : { made, onProject: undefined, onStart: `Knob ${round}` }, - ) + // Before that commit: no project, the draft on the start screen. After it: the project holds the draft until the + // message goes out, and the start screen holds none. + expect(made ? state.drafts.new : state.drafts.new?.text).toEqual(made ? undefined : `Knob ${round}`) + if (made) expect([undefined, `Knob ${round}`]).toContain(state.drafts[id]?.text) + else expect(state.drafts[id]).toBeUndefined() } await app.quit() }) @@ -109,8 +110,7 @@ describe('app state', () => { writeFileSync(projects, '') const app = spawnSidecar(data, projects) await app.next() - await app.call('drafts.set', { key: 'new', text: 'A knob', attachments: [] }) - await expect(app.call('projects.create', { title: 'A knob' })).rejects.toThrow() + await expect(app.sendDraft('new', 'A knob')).rejects.toThrow() expect(await app.call('state.get')).toMatchObject({ projects: [], drafts: { new: { text: 'A knob', attachments: [] } } }) await app.quit() }) @@ -119,34 +119,11 @@ describe('app state', () => { const { data, projects } = scratch() const app = spawnSidecar(data, projects) await app.next() - const first = (await app.call('projects.create', { title: 'A knob' })) as { id: string } + const first = await app.sendDraft('new', 'A knob') // A folder the store does not list, such as one an older app made, under the id the next project would take. - mkdirSync(join(projects, `${first.id}-2`), { recursive: true }) - const second = (await app.call('projects.create', { title: 'A knob' })) as { id: string } - expect(second.id).toBe(`${first.id}-3`) - await app.quit() - }) - - test("a first message's draft revision makes one project, even when asked again", async () => { - const { data, projects } = scratch() - const app = spawnSidecar(data, projects) - await app.next() - const first = (await app.call('projects.create', { title: 'New part', rev: 7 })) as { id: string } - const again = (await app.call('projects.create', { title: 'New part', rev: 7 })) as { id: string; draft: unknown } - expect(again).toMatchObject({ id: first.id, draft: null }) - expect(((await app.call('state.get')) as { projects: unknown[] }).projects).toHaveLength(1) - const other = (await app.call('projects.create', { title: 'New part', rev: 8 })) as { id: string } - expect(other.id).not.toBe(first.id) - await app.quit() - }) - - test('a project made from the start screen leaves a start screen draft that holds other text where it is', async () => { - const { data, projects } = scratch() - const app = spawnSidecar(data, projects) - await app.next() - await app.call('drafts.set', { key: 'new', text: 'A shelf bracket', attachments: [] }) - expect(((await app.call('projects.create', { title: 'A knob' })) as { draft: unknown }).draft).toBeNull() - expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ new: { text: 'A shelf bracket', attachments: [], rev: expect.any(Number) } }) + mkdirSync(join(projects, `${first.project}-2`), { recursive: true }) + const second = await app.sendDraft('new', 'A knob') + expect(second.project).toBe(`${first.project}-3`) await app.quit() }) @@ -171,21 +148,8 @@ describe('app state', () => { await app.next() const { drafts } = (await app.call('state.get')) as { drafts: Record } expect(drafts.new).toEqual({ text: 'A knob', attachments: [], rev: expect.any(Number) }) - const project = (await app.call('projects.create', { title: 'A knob' })) as { id: string } - await app.call('chat.send', { project: project.id, requestId: 'r1', text: 'A knob', attachments: [], draftRev: drafts.new?.rev }) + await app.call('chat.send', { key: 'new', rev: drafts.new?.rev }) expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({}) await app.quit() }) - - test('a project made from the start screen takes the start screen draft with it', async () => { - const { data, projects } = scratch() - const app = spawnSidecar(data, projects) - await app.next() - const { rev } = (await app.call('drafts.set', { key: 'new', text: 'A knob', attachments: ['refs/shaft.png'] })) as { rev: number } - const project = (await app.call('projects.create', { title: 'A knob' })) as { id: string; draft: unknown } - // It answers with the draft it took, so the window need not guess. - expect(project.draft).toEqual({ text: 'A knob', attachments: ['refs/shaft.png'], rev }) - expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({ [project.id]: { text: 'A knob', attachments: ['refs/shaft.png'], rev: expect.any(Number) } }) - await app.quit() - }) }) diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index 57f34839..d810ee92 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -194,11 +194,9 @@ describe('the update gate', () => { const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) await app.next() await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) expect(app.gate.requests).toEqual([]) - await app.call('chat.send', { project, requestId: 'r1', text: 'please wait', attachments: [] }) + const { project } = await app.sendDraft('new', 'please wait') expect(app.gate.requests).toEqual([true]) const started = join(where.projects, project, 'started') while (!existsSync(started)) await Bun.sleep(25) @@ -209,8 +207,8 @@ describe('the update gate', () => { expect(app.gate.requests).toEqual([true, false]) app.gate.installing = true - await app.call('drafts.set', { key: project, text: 'make it red', attachments: [] }) - await expect(app.call('chat.send', { project, requestId: 'r2', text: 'make it red', attachments: [] })).rejects.toThrow('An update is installing.') + const { rev } = (await app.call('drafts.set', { key: project, text: 'make it red', attachments: [] })) as { rev: number } + await expect(app.call('chat.send', { key: project, rev })).rejects.toThrow('An update is installing.') const view = (await app.call('chat.open', { project })) as ChatView expect(view.entries.filter((entry) => entry.kind === 'user')).toHaveLength(1) expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toMatchObject({ text: 'make it red', attachments: [] }) @@ -222,8 +220,7 @@ describe('the update gate', () => { const first = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) await first.next() await first.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) - const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } - await first.call('chat.send', { project, requestId: 'r1', text: 'please wait', attachments: [] }) + const { project } = await first.sendDraft('new', 'please wait') const started = join(where.projects, project, 'started') while (!existsSync(started)) await Bun.sleep(25) first.child.kill('SIGKILL') @@ -252,8 +249,7 @@ describe('the update gate', () => { const first = spawnSidecar(where.data, where.projects, new Map(), { script: 'models', env: where.env }) await first.next() await first.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) - const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } - await first.call('chat.send', { project, requestId: 'r1', text: 'please wait', attachments: [] }) + const { project } = await first.sendDraft('new', 'please wait') const started = join(where.projects, project, 'started') while (!existsSync(started)) await Bun.sleep(25) first.child.kill('SIGKILL') @@ -275,8 +271,7 @@ describe('the update gate', () => { const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra', env: where.env }) await app.next() await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.send', { project, requestId: 'r1', text: 'go', attachments: [] }) + const { project } = await app.sendDraft('new', 'go') await app.until((event) => event.type === 'chat' && event.waiting !== null) app.gate.installing = true await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) @@ -294,8 +289,7 @@ describe('the update gate', () => { const app = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) await app.next() await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.send', { project, requestId: 'r1', text: 'go', attachments: [] }) + const { project } = await app.sendDraft('new', 'go') await app.until((event) => event.type === 'chat' && event.waiting !== null) app.gate.installing = true await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) @@ -331,9 +325,7 @@ describe('the update gate', () => { const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra-and-read', env: where.env }) await app.next() await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - writeFileSync(join(where.projects, project, 'note.txt'), 'a note') - await app.call('chat.send', { project, requestId: 'r1', text: 'go', attachments: [] }) + const { project } = await app.sendDraft('new', 'go') await app.until((event) => event.type === 'chat' && event.waiting !== null) const deadline = Date.now() + 5_000 while (app.gate.requests.at(-1) !== false && Date.now() < deadline) await Bun.sleep(25) @@ -346,8 +338,7 @@ describe('the update gate', () => { const app = spawnSidecar(where.data, where.projects, new Map(), { script: 'astra', env: where.env }) await app.next() await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.send', { project, requestId: 'r1', text: 'go', attachments: [] }) + const { project } = await app.sendDraft('new', 'go') await app.until((event) => event.type === 'chat' && event.waiting !== null) const deadline = Date.now() + 5_000 while (app.gate.requests.at(-1) !== false && Date.now() < deadline) await Bun.sleep(25) diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index 87441ed2..a426fd0d 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -20,14 +20,18 @@ function scratch(env: Record = {}) { const start = (where: ReturnType, script: string, version?: string) => spawnSidecar(where.data, where.projects, new Map(), { script, env: where.env, ...(version === undefined ? {} : { version }) }) -async function newProject(app: App) { +/** Sends a first message from the start screen, which makes its project, and answers that project. */ +async function firstSend(app: App, text = 'go') { await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) - const { id } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project: id }) - return id + return (await app.sendDraft('new', text)).project } -const send = (app: App, project: string, requestId: string, text = 'go') => app.call('chat.send', { project, requestId, text, attachments: [] }) +/** A project whose first message every script answers with `made`, for a test that sets up its folder first. */ +async function newProject(app: App) { + const project = await firstSend(app, 'make the project') + await answered(app, project, 'made') + return project +} /** Waits until the chat holds this answer, already committed or in a later change. */ async function answered(app: App, project: string, text: string) { @@ -71,8 +75,7 @@ describe('the run tool', () => { const where = scratch({ M3D_SECRET: 'leak' }) const app = start(where, 'env') await app.next() - const project = await newProject(app) - await send(app, project, 'r1') + const project = await firstSend(app) await answered(app, project, 'exit code 0\nsecret=none op=set\n') // One operation: its receipt, its log, and its exit code. const files = readdirSync(join(where.projects, project, '.m3d', 'ops')) @@ -84,8 +87,7 @@ describe('the run tool', () => { const where = scratch() const app = start(where, 'slow') await app.next() - const project = await newProject(app) - await send(app, project, 'r1') + const project = await firstSend(app) await answered(app, project, 'timed') expect((await toolResults(app, project))[0]).toMatchObject({ name: 'run', failed: true }) await Bun.sleep(2500) @@ -97,8 +99,7 @@ describe('the run tool', () => { const where = scratch() const app = start(where, 'orphan') await app.next() - const project = await newProject(app) - await send(app, project, 'r1') + const project = await firstSend(app) while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) app.child.kill('SIGKILL') await app.child.exited @@ -110,8 +111,7 @@ describe('the run tool', () => { const where = scratch() const first = start(where, 'quick') await first.next() - const project = await newProject(first) - await send(first, project, 'r1') + const project = await firstSend(first) while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(10) first.child.kill('SIGKILL') await first.child.exited @@ -227,7 +227,7 @@ describe('the file tools', () => { symlinkSync(join(outside, 'made-outside.txt'), join(where.projects, project, 'dangling')) // A link to a file not made yet, through `linked` and then up: the system climbs from where `linked` leads. symlinkSync('linked/../climbed.txt', join(where.projects, project, 'climb')) - await send(app, project, 'r1') + await app.sendDraft(project, 'go') await answered(app, project, 'tried') const results = await toolResults(app, project) expect(results.map((r) => [r.name, r.failed])).toEqual([['read', true], ['write', true], ['edit', true], ['write', true], ['write', true]]) @@ -241,17 +241,25 @@ describe('the file tools', () => { }) describe('a picture sent with a message', () => { - test('never reads a file outside the project through a link', async () => { + test('is never copied through a link out of the project, and a link left in its place is never read', async () => { const where = scratch() const app = start(where, 'models') await app.next() const project = await newProject(app) + const photo = join(where.dir, 'photo.jpg') + writeFileSync(photo, 'jpg') + const outside = join(where.dir, 'outside') + mkdirSync(outside) + symlinkSync(outside, join(where.projects, project, 'refs')) + const rev = ((await app.call('drafts.set', { key: project, text: 'like this', attachments: [photo] })) as { rev: number }).rev + await expect(app.call('chat.send', { key: project, rev })).rejects.toThrow('refs/ leads outside the project folder') + expect(readdirSync(outside)).toEqual([]) + + rmSync(join(where.projects, project, 'refs')) writeFileSync(join(where.dir, 'secret.jpg'), 'secret') - mkdirSync(join(where.projects, project, 'refs'), { recursive: true }) - symlinkSync(join(where.dir, 'secret.jpg'), join(where.projects, project, 'refs', 'photo.jpg')) - await expect(app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['refs/photo.jpg'] })).rejects.toThrow( - 'is not a file in the project folder', - ) + mkdirSync(join(where.projects, project, 'refs', `draft-${rev}`), { recursive: true }) + symlinkSync(join(where.dir, 'secret.jpg'), join(where.projects, project, 'refs', `draft-${rev}`, 'photo.jpg')) + await expect(app.call('chat.send', { key: project, rev })).rejects.toThrow('is not a file in the project folder') await app.quit() }) @@ -260,12 +268,13 @@ describe('a picture sent with a message', () => { const app = start(where, 'models') await app.next() const project = await newProject(app) - const photo = join(where.projects, project, '..photo.png') + const photo = join(where.dir, '..photo.png') writeFileSync(photo, 'png') - await app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['..photo.png'] }) + const { rev } = await app.sendDraft(project, 'like this', [photo]) await answered(app, project, 'answered by faux-a') + expect(readdirSync(join(where.projects, project, 'refs', `draft-${rev}`))).toEqual(['..photo.png']) rmSync(photo) - await app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['..photo.png'] }) + await app.call('chat.send', { key: project, rev }) await app.quit() }) @@ -274,10 +283,9 @@ describe('a picture sent with a message', () => { const app = start(where, 'models') await app.next() const project = await newProject(app) - expect(Bun.spawnSync(['mkfifo', join(where.projects, project, 'pipe.png')]).exitCode).toBe(0) - await expect(app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['pipe.png'] })).rejects.toThrow( - 'is not a file in the project folder', - ) + const pipe = join(where.dir, 'pipe.png') + expect(Bun.spawnSync(['mkfifo', pipe]).exitCode).toBe(0) + await expect(app.sendDraft(project, 'like this', [pipe])).rejects.toThrow("A picture in this message can't be read: pipe.png. Take it out and send again.") await app.quit() }) }) @@ -287,8 +295,7 @@ describe('Astra', () => { const where = scratch() const app = start(where, 'astra') await app.next() - const project = await newProject(app) - await send(app, project, 'r1') + const project = await firstSend(app) const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) expect(waiting.waiting).toEqual({ kind: 'login', @@ -308,9 +315,8 @@ describe('Astra', () => { const where = scratch() const app = start(where, 'astra') await app.next() - const project = await newProject(app) await app.call('auth.setKey', { provider: 'openai-codex', key: 'refused' }) - await send(app, project, 'r1') + const project = await firstSend(app) const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null, 30_000) expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'openai-codex' }) expect(waiting.running).toBe(true) @@ -325,9 +331,8 @@ describe('Astra', () => { const where = scratch({ M3D_ASTRA_GATE: gate }) const app = start(where, 'astra') await app.next() - const project = await newProject(app) await app.call('auth.setKey', { provider: 'openai-codex', key: 'refused' }) - await send(app, project, 'r1') + const project = await firstSend(app) while (!existsSync(join(gate, 'asked'))) await Bun.sleep(25) await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) writeFileSync(join(gate, 'release'), '') @@ -340,8 +345,7 @@ describe('Astra', () => { const keychain = new Map() const first = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) await first.next() - const project = await newProject(first) - await send(first, project, 'r1') + const project = await firstSend(first) await first.until((event) => event.type === 'chat' && event.waiting !== null) first.child.kill('SIGKILL') await first.child.exited @@ -361,8 +365,7 @@ describe('a restart', () => { const where = scratch() const first = start(where, 'fails-after') await first.next() - const project = await newProject(first) - await send(first, project, 'r1') + const project = await firstSend(first) while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) first.child.kill('SIGKILL') await first.child.exited @@ -380,9 +383,7 @@ describe('a restart', () => { const first = start(where, 'models', '1.0.0') await first.next() await first.call('settings.set', { model: { provider: 'anthropic', modelId: 'claude-opus-5-5' } }) - const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } - await first.call('chat.open', { project }) - await send(first, project, 'r1') + const { project } = await first.sendDraft('new', 'go') await first.until((event) => event.type === 'chat' && event.waiting !== null) await first.quit() @@ -397,8 +398,7 @@ describe('a restart', () => { const where = scratch() const first = start(where, 'models', '1.0.0') await first.next() - const project = await newProject(first) - await send(first, project, 'r1', 'please wait') + const project = await firstSend(first, 'please wait') while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) first.child.kill('SIGKILL') await first.child.exited diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts index d3f8eaba..b2084aa8 100644 --- a/sidecar/test/waiting.test.ts +++ b/sidecar/test/waiting.test.ts @@ -44,9 +44,7 @@ describe('a turn whose provider is not signed in', () => { const keychain = new Map() const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) await first.next() - const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } - await first.call('chat.open', { project }) - await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await first.sendDraft('new', 'A 20 mm cube') const waiting = await first.until((event) => event.type === 'chat' && event.waiting !== null) expect(waiting.waiting).toEqual({ kind: 'login', @@ -82,10 +80,8 @@ describe('a turn whose provider is not signed in', () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) await app.next() - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) - await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') while (proxy.seen.length === 0) await Bun.sleep(10) await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) await Bun.sleep(200) @@ -106,10 +102,8 @@ describe('a turn whose provider is not signed in', () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) await app.next() - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) - await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') while (proxy.seen.length === 0) await Bun.sleep(10) await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) // An update starts installing while the old request is still out. @@ -138,10 +132,8 @@ describe('a turn whose provider is not signed in', () => { const start = () => spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) const first = start() await first.next() - const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } - await first.call('chat.open', { project }) await first.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) - await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await first.sendDraft('new', 'A 20 mm cube') await first.until((event) => event.type === 'chat' && event.waiting !== null) // A new login is saved while an update installs, so the turn waits for the pass. first.gate.installing = true @@ -178,13 +170,11 @@ describe('a turn whose provider is not signed in', () => { hold: (request, answer) => (holding && request.method === 'keychain.get' ? held.push(answer) : answer()), }) await app.next() - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) const nextRead = async (count: number) => { while (held.length < count) await Bun.sleep(10) } // The turn's request finds no login and fails. Its failure is read, and that read asks the Keychain again. - await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') await nextRead(1) held[0]?.() await nextRead(2) @@ -207,9 +197,7 @@ describe('a turn whose provider is not signed in', () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) await app.next() - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) - await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') await app.until((event) => event.type === 'chat' && event.waiting !== null) await app.call('chat.stop', { project }) await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) @@ -226,10 +214,8 @@ describe('a turn whose provider is not signed in', () => { const keychain = new Map() const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) await first.next() - const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } - await first.call('chat.open', { project }) await first.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) - await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await first.sendDraft('new', 'A 20 mm cube') await first.until((event) => event.type === 'chat' && event.waiting !== null) await first.quit() const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) @@ -252,10 +238,8 @@ describe('a turn whose provider is not signed in', () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) await app.next() - const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } - await app.call('chat.open', { project }) await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) - await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const { project } = await app.sendDraft('new', 'A 20 mm cube') const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'anthropic' }) await Bun.sleep(500) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index f313a20e..a93efcdb 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -25,6 +25,7 @@ vi.mock('@tauri-apps/api/webview', () => ({ }), })) +import type { Project } from '../../sidecar/src/protocol' import StudioApp from './StudioApp' // jsdom has no layout, so the chat's scroll to its newest line is a no-op here, and no object URLs for previews. @@ -50,75 +51,110 @@ const emit = (event: object) => act(() => listeners.sidecar.forEach((listener) = const prompt = () => screen.getByTestId('prompt') as HTMLTextAreaElement const type = (text: string) => fireEvent.change(prompt(), { target: { value: text } }) +const drop = (paths: string[]) => act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths } }))) +const pause = (ms: number) => act(() => new Promise((resolve) => setTimeout(resolve, ms))) -type Calls = Record) => Promise> +type Params = Record +/** A call's own answer from the mocked sidecar, which an override may hold, fail, or pass on. */ +type Calls = Record Promise) => Promise> +type StoredDraft = { text: string; attachments: string[]; rev: number } -/** The sidecar as the window sees it: one `sidecar` command, by method. */ /** The revisions the mocked sidecar gives saved drafts, growing as the real one's do. */ let draftRevs = 0 -function mockSidecar(overrides: Calls = {}, projects = [projectB], drafts: Record = {}) { - // The drafts the sidecar holds, as the window saved them, so a new project takes the start screen's draft by the - // sidecar's own rule: when its text is the project's title. - const held: Record = {} - const madeFor = new Map() - for (const [key, draft] of Object.entries(drafts)) held[key] = { text: draft.text, attachments: draft.attachments ?? [], rev: draft.rev ?? 0 } - const call = (method: string, params: Record): Promise => { - const override = overrides[method] - if (method === 'drafts.set') { - const saved = override ? override(params) : Promise.resolve({ rev: ++draftRevs }) - return saved.then((value) => { - const { rev } = value as { rev: number } - const draft = { text: String(params.text), attachments: params.attachments as string[], rev } - if (draft.text === '' && draft.attachments.length === 0) delete held[String(params.key)] - else held[String(params.key)] = draft - return value - }) - } - if (method === 'projects.create') { - const again = typeof params.rev === 'number' ? madeFor.get(params.rev) : undefined - if (again !== undefined) return Promise.resolve({ ...again, draft: null }) - const made = override ? override(params) : Promise.resolve(null) - return made.then((project) => { - if (project === null || typeof project !== 'object') return project - if (typeof params.rev === 'number') madeFor.set(params.rev, project as object) - if ('draft' in project) return project - const id = (project as { id: string }).id - const start = held.new - if (start === undefined || start.text.trim() !== String(params.title).trim()) return { ...project, draft: null } - held[id] = start - delete held.new - return { ...project, draft: start } - }) +const CHANGED = 'The draft changed before it went out. Send it again.' +const LOST = 'The engine restarted before it answered. Try again.' + +/** Holds each call until `release`, then answers as the mocked sidecar would. */ +function held() { + let release = () => {} + return { + call: (_: Params, real: () => Promise) => new Promise((resolve) => (release = () => resolve(real()))), + release: () => act(async () => release()), + } +} + +/** A send whose first answer is lost after the sidecar sent it, as when the engine restarts. Later sends answer. */ +function lostOnce() { + let sends = 0 + return (_: Params, real: () => Promise) => (sends++ === 0 ? real().then(() => Promise.reject(LOST)) : real()) +} + +/** + * The sidecar as the window sees it: one `sidecar` command, by method. It keeps the drafts the window saves, and sends + * one by its revision as the real sidecar does: a start screen send makes the project for that revision, or finds the + * one it made, and moves the start draft there only when it is that revision. A send already made changes nothing. A + * send removes the draft it sent. It answers with what it keeps, for a test to read. + */ +function mockSidecar(overrides: Calls = {}, projects: Project[] = [projectB], drafts: Record = {}) { + const store = { + projects: [...projects], + drafts: Object.fromEntries( + Object.entries(drafts).map(([key, draft]) => [key, { text: draft.text, attachments: draft.attachments ?? [], rev: draft.rev ?? ++draftRevs }]), + ) as Record, + madeFor: new Map(), + /** Each message that went out. */ + sent: [] as { project: string; rev: number; text: string; attachments: string[] }[], + } + const send = (key: string, rev: number): Promise => { + let project = key === 'new' ? store.madeFor.get(rev) : store.projects.find((p) => p.id === key) + if (project === undefined) { + const start = store.drafts.new + if (key !== 'new' || start?.rev !== rev) return Promise.reject(CHANGED) + const title = start.text.split('\n')[0]?.trim() || 'New part' + project = { id: `2026-11-06-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, conversation: 10 + store.projects.length, title, createdMs: 10 + store.projects.length } + store.projects.push(project) + store.madeFor.set(rev, project) + store.drafts[project.id] = start + delete store.drafts.new } - if (override) return override(params) - return defaults(method, params) + const id = project.id + if (store.sent.some((message) => message.project === id && message.rev === rev)) return Promise.resolve({ project }) + const draft = store.drafts[id] + if (draft?.rev !== rev) return Promise.reject(CHANGED) + store.sent.push({ project: id, rev, text: draft.text, attachments: draft.attachments }) + delete store.drafts[id] + return Promise.resolve({ project }) } - const defaults = (method: string, params: Record): Promise => { + const defaults = (method: string, params: Params): Promise => { switch (method) { case 'app.ping': return Promise.resolve({ version: '1.2.3' }) case 'state.get': - return Promise.resolve({ setup: { step: 'ready', done: true }, settings, projects, drafts, importBlocked: null }) + return Promise.resolve(JSON.parse(JSON.stringify({ setup: { step: 'ready', done: true }, settings, projects: store.projects, drafts: store.drafts, importBlocked: null }))) case 'chat.open': return Promise.resolve(view(String(params.project))) case 'setup.check': return Promise.resolve([]) + case 'drafts.set': { + const rev = ++draftRevs + const draft = { text: String(params.text), attachments: params.attachments as string[], rev } + if (draft.text === '' && draft.attachments.length === 0) delete store.drafts[String(params.key)] + else store.drafts[String(params.key)] = draft + return Promise.resolve({ rev }) + } + case 'chat.send': + return send(String(params.key), Number(params.rev)) default: return Promise.resolve(null) } } - invokeMock.mockImplementation((command: string, args: { method: string; params: Record }) => { + invokeMock.mockImplementation((command: string, args: { method: string; params: Params }) => { if (command === 'update_check') return Promise.resolve({ status: 'upToDate' }) if (command === 'studio_files') return Promise.resolve([]) - if (overrides[command]) return overrides[command](args as unknown as Record) if (command !== 'sidecar') return Promise.resolve(undefined) - return call(args.method, args.params) + const real = () => defaults(args.method, args.params) + const override = overrides[args.method] + return override ? override(args.params, real) : real() }) + return store } const sidecarCalls = (method: string) => invokeMock.mock.calls.filter(([command, args]) => command === 'sidecar' && args.method === method).map(([, args]) => args.params) +const sends = () => sidecarCalls('chat.send') +const saves = (key?: string) => sidecarCalls('drafts.set').filter((save) => key === undefined || save.key === key) +const last = (items: T[]) => items[items.length - 1] describe('StudioApp', () => { it('shows the engine version the sidecar answers with', async () => { @@ -138,65 +174,60 @@ describe('StudioApp', () => { }) it('leaves the project the person moved to alone when a slow send from the start screen finishes', async () => { - let finishCreate: (project: unknown) => void = () => {} - mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) + const send = held() + mockSidecar({ 'chat.send': send.call }) render() await screen.findByText('Project B') - fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'A clip for my desk' } }) + type('A clip for my desk') fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toEqual([{ key: 'new', rev: expect.any(Number) }])) fireEvent.click(screen.getByText('Project B')) await screen.findByRole('banner') - fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'draft for B' } }) - - await act(async () => finishCreate({ id: '2026-11-06-a-clip', conversation: 4, title: 'A clip for my desk', createdMs: 2 })) - await waitFor(() => expect(sidecarCalls('chat.send')).toEqual([expect.objectContaining({ project: '2026-11-06-a-clip', text: 'A clip for my desk' })])) - expect(sidecarCalls('chat.send')[0]?.requestId).toEqual(expect.any(String)) + type('draft for B') + await send.release() + const sidebar = within(screen.getByRole('navigation', { name: 'Projects' })) + await waitFor(() => expect(sidebar.getByText('A clip for my desk')).toBeTruthy()) expect(screen.getByRole('banner').textContent).toBe('Project B') - expect((screen.getByTestId('prompt') as HTMLTextAreaElement).value).toBe('draft for B') + expect(prompt().value).toBe('draft for B') }) - it("keeps a slow send's failure with the project it was sent to, and sending again reuses its request id", async () => { + it("keeps a slow send's failure with the project it was sent to, and sending again names the same revision", async () => { let failSend: (reason: string) => void = () => {} - let sends = 0 - mockSidecar( - { 'chat.send': () => (++sends === 1 ? new Promise((_, reject) => (failSend = reject)) : Promise.resolve(null)) }, - [projectA, projectB], - ) + let tries = 0 + mockSidecar({ 'chat.send': (_, real) => (++tries === 1 ? new Promise((_, reject) => (failSend = reject)) : real()) }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project A')) await screen.findByRole('banner') - fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'A clip for my desk' } }) + type('A clip for my desk') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) - await act(async () => failSend('The engine restarted before it answered. Try again.')) + await act(async () => failSend(LOST)) expect(screen.queryByRole('alert')).toBeNull() fireEvent.click(screen.getByText('Project A')) expect((await screen.findByRole('alert')).textContent).toContain('The engine restarted before it answered.') - fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'A clip for my desk' } }) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) - const [first, second] = sidecarCalls('chat.send') - expect(second?.requestId).toBe(first?.requestId) + await waitFor(() => expect(sends()).toHaveLength(2)) + const [first, second] = sends() + expect(second).toEqual(first) + expect(saves()).toHaveLength(1) // The send went through this time, so its failure is no longer shown. await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()) }) it('keeps a project made while the saved state was still on its way', async () => { let finishState: (state: unknown) => void = () => {} - mockSidecar({ - 'state.get': () => new Promise((resolve) => (finishState = resolve)), - 'projects.create': () => Promise.resolve({ id: '2026-11-06-a-clip', conversation: 4, title: 'A clip for my desk', createdMs: 3 }), - }) + mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) render() - fireEvent.change(await screen.findByTestId('prompt'), { target: { value: 'A clip for my desk' } }) + type('A clip for my desk') + await waitFor(() => expect((screen.getByTestId('send') as HTMLButtonElement).disabled).toBe(false)) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('A clip for my desk')) await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: {} })) const sidebar = within(screen.getByRole('navigation', { name: 'Projects' })) expect(sidebar.getByText('A clip for my desk')).toBeTruthy() @@ -219,30 +250,26 @@ describe('StudioApp', () => { mockSidecar({}, [projectB], { [projectB.id]: { text: 'make it red' } }) render() fireEvent.click(await screen.findByText('Project B')) - await waitFor(() => expect((screen.getByTestId('prompt') as HTMLTextAreaElement).value).toBe('make it red')) - fireEvent.change(screen.getByTestId('prompt'), { target: { value: 'make it blue' } }) - await waitFor(() => expect(sidecarCalls('drafts.set')).toEqual([{ key: projectB.id, text: 'make it blue', attachments: [] }])) + await waitFor(() => expect(prompt().value).toBe('make it red')) + type('make it blue') + await waitFor(() => expect(saves()).toEqual([{ key: projectB.id, text: 'make it blue', attachments: [] }])) }) - it('sending again after a lost reply sends the same pictures under the same request id', async () => { - let sends = 0 - let imports = 0 - mockSidecar({ - studio_import_file: () => Promise.resolve(imports++ === 0 ? 'refs/photo.jpg' : `refs/photo-${imports}.jpg`), - 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), - }) + it('sends again after a lost answer under the same revision, with no new save', async () => { + const store = mockSidecar({ 'chat.send': lostOnce() }) render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + await drop(['/Users/me/photo.jpg']) type('A clip like this') fireEvent.click(screen.getByTestId('send')) await screen.findByRole('alert') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) - const [first, second] = sidecarCalls('chat.send') + await waitFor(() => expect(sends()).toHaveLength(2)) + const [first, second] = sends() expect(second).toEqual(first) - expect(first?.attachments).toEqual(['refs/photo.jpg']) + expect(saves()).toEqual([{ key: projectB.id, text: 'A clip like this', attachments: ['/Users/me/photo.jpg'] }]) + expect(store.sent).toEqual([{ project: projectB.id, rev: first?.rev, text: 'A clip like this', attachments: ['/Users/me/photo.jpg'] }]) }) it("keeps a draft's pictures, and brings them back when the project opens again", async () => { @@ -250,11 +277,8 @@ describe('StudioApp', () => { const first = render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) - await waitFor(() => { - const saves = sidecarCalls('drafts.set') - expect(saves[saves.length - 1]).toEqual({ key: projectB.id, text: '', attachments: ['/Users/me/photo.jpg'] }) - }) + await drop(['/Users/me/photo.jpg']) + await waitFor(() => expect(last(saves())).toEqual({ key: projectB.id, text: '', attachments: ['/Users/me/photo.jpg'] })) first.unmount() mockSidecar({}, [projectB], { [projectB.id]: { text: 'like this', attachments: ['/Users/me/photo.jpg'] } }) @@ -273,7 +297,7 @@ describe('StudioApp', () => { type('half a thought') await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) await waitFor(() => expect(invokeMock.mock.calls.some(([command]) => command === 'studio_flushed')).toBe(true)) - expect(sidecarCalls('drafts.set')).toEqual([{ key: projectB.id, text: 'half a thought', attachments: [] }]) + expect(saves()).toEqual([{ key: projectB.id, text: 'half a thought', attachments: [] }]) }) it('keeps Send off until the agent can run, and names the fix', async () => { @@ -332,44 +356,25 @@ describe('StudioApp', () => { }) it('never brings a sent picture back in the next draft', async () => { - let finishSend: (value: unknown) => void = () => {} - mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), studio_import_file: () => Promise.resolve('refs/photo.jpg') }, [projectA, projectB]) + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + await drop(['/Users/me/photo.jpg']) type('Like this') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) type('And thinner') - await act(async () => finishSend(null)) - await new Promise((resolve) => setTimeout(resolve, 500)) - const saves = sidecarCalls('drafts.set') - expect(saves[saves.length - 1]).toEqual({ key: projectB.id, text: 'And thinner', attachments: [] }) + await send.release() + await pause(500) + expect(last(saves())).toEqual({ key: projectB.id, text: 'And thinner', attachments: [] }) fireEvent.click(screen.getByText('Project A')) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) expect(screen.queryByTestId('attachments')).toBeNull() }) - it('takes a picture sent as a first message off the start screen', async () => { - mockSidecar({ - 'projects.create': () => Promise.resolve({ id: '2026-11-06-new-part', conversation: 4, title: 'New part', createdMs: 3 }), - studio_import_file: () => Promise.resolve('refs/photo.jpg'), - }) - render() - await screen.findByText('Project B') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) - fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - fireEvent.click(screen.getByTestId('new-project')) - expect(screen.queryByTestId('attachments')).toBeNull() - await waitFor(() => { - const saves = sidecarCalls('drafts.set').filter((save) => save.key === 'new') - expect(saves[saves.length - 1]).toEqual({ key: 'new', text: '', attachments: [] }) - }) - }) - it('keeps a pasted picture on the screen it was pasted on, even when the person leaves before it is kept', async () => { let finishPicture: (path: unknown) => void = () => {} mockSidecar({ 'drafts.picture': () => new Promise((resolve) => (finishPicture = resolve)) }, [projectA, projectB]) @@ -384,9 +389,27 @@ describe('StudioApp', () => { await act(async () => finishPicture('/state/pictures/1/shot.png')) expect(screen.queryByTestId('attachments')).toBeNull() // It lands in the draft of the screen it was pasted on, and is there when the person goes back. - await waitFor(() => expect(sidecarCalls('drafts.set')).toContainEqual({ key: projectA.id, text: '', attachments: ['/state/pictures/1/shot.png'] })) + await waitFor(() => expect(saves()).toContainEqual({ key: projectA.id, text: '', attachments: ['/state/pictures/1/shot.png'] })) + fireEvent.click(screen.getByText('Project A')) + await waitFor(() => expect(screen.getByLabelText('Remove shot.png')).toBeTruthy()) + }) + + it('keeps a paste that lands after the person left in that screen\'s draft, beside the text typed there', async () => { + let finishPicture: (path: unknown) => void = () => {} + mockSidecar({ 'drafts.picture': () => new Promise((resolve) => (finishPicture = resolve)) }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + type('like this') + fireEvent.paste(prompt(), { clipboardData: { files: [new File(['png'], 'shot.png', { type: 'image/png' })] } }) + await waitFor(() => expect(sidecarCalls('drafts.picture')).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await act(async () => finishPicture('/state/pictures/1/shot.png')) + await waitFor(() => expect(last(saves(projectA.id))).toEqual({ key: projectA.id, text: 'like this', attachments: ['/state/pictures/1/shot.png'] })) fireEvent.click(screen.getByText('Project A')) - await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('shot.png')) + await waitFor(() => expect(screen.getByLabelText('Remove shot.png')).toBeTruthy()) + expect(prompt().value).toBe('like this') }) it("brings the start screen's saved pictures back even when the person typed before the saved state arrived", async () => { @@ -395,49 +418,45 @@ describe('StudioApp', () => { render() type('a hook') await act(async () => - finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'] } }, importBlocked: null }), + finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'], rev: 1 } }, importBlocked: null }), ) expect(screen.getByTestId('attachments').textContent).toContain('hook.jpg') expect(prompt().value).toBe('a hook') - await waitFor(() => { - const saves = sidecarCalls('drafts.set') - expect(saves[saves.length - 1]).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] }) - }) + await waitFor(() => expect(last(saves())).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] })) }) it("leaves another project's same picture alone when a send goes through", async () => { - let finishSend: (value: unknown) => void = () => {} - mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), studio_import_file: () => Promise.resolve('refs/photo.jpg') }, [projectA, projectB]) + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project A')) await screen.findByRole('banner') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + await drop(['/Users/me/photo.jpg']) type('Like this') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) - await act(async () => finishSend(null)) - await new Promise((resolve) => setTimeout(resolve, 500)) - const saves = sidecarCalls('drafts.set').filter((save) => save.key === projectB.id) - expect(saves[saves.length - 1]).toEqual({ key: projectB.id, text: '', attachments: ['/Users/me/photo.jpg'] }) + await drop(['/Users/me/photo.jpg']) + await send.release() + await pause(500) + expect(last(saves(projectB.id))).toEqual({ key: projectB.id, text: '', attachments: ['/Users/me/photo.jpg'] }) }) it('keeps a newer saved draft that reads the same as the sent text', async () => { - let finishSend: (value: unknown) => void = () => {} - mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), studio_import_file: () => Promise.resolve('refs/a.jpg') }, [projectA, projectB]) + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') type('Make it red') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) type('Make it blue') type('Make it red') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/new.jpg'] } }))) - await new Promise((resolve) => setTimeout(resolve, 500)) - await act(async () => finishSend(null)) + await drop(['/Users/me/new.jpg']) + await pause(500) + await send.release() fireEvent.click(screen.getByText('Project A')) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) @@ -457,7 +476,7 @@ describe('StudioApp', () => { fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) await act(async () => finishRead(new ArrayBuffer(3))) - await new Promise((resolve) => setTimeout(resolve, 50)) + await pause(50) expect(screen.queryByTestId('attachments')).toBeNull() }) @@ -466,84 +485,55 @@ describe('StudioApp', () => { mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) render() type('a hook') - await new Promise((resolve) => setTimeout(resolve, 500)) + await pause(500) await act(async () => - finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'] } }, importBlocked: null }), + finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'], rev: 1 } }, importBlocked: null }), ) expect(screen.getByTestId('attachments').textContent).toContain('hook.jpg') - await waitFor(() => { - const saves = sidecarCalls('drafts.set') - expect(saves[saves.length - 1]).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] }) - }) + await waitFor(() => expect(last(saves())).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] })) }) it('takes the sent pictures out of the composer when the person left and came back before the send went through', async () => { - let finishSend: (value: unknown) => void = () => {} - mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), studio_import_file: () => Promise.resolve('refs/photo.jpg') }, [projectA, projectB]) + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project A')) await screen.findByRole('banner') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + await drop(['/Users/me/photo.jpg']) type('Like this') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) fireEvent.click(screen.getByText('Project A')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) - await act(async () => finishSend(null)) + await send.release() expect(screen.queryByTestId('attachments')).toBeNull() }) - it('names the saved draft a send went out with, so the sidecar removes that one and no newer one', async () => { + it('sends the revision the draft was saved as, and names nothing else', async () => { mockSidecar() render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') type('Make it red') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - const saved = invokeMock.mock.results.filter((_, i) => invokeMock.mock.calls[i]?.[1]?.method === 'drafts.set') - const rev = ((await saved[saved.length - 1]?.value) as { rev: number }).rev - expect(sidecarCalls('chat.send')[0]?.draftRev).toBe(rev) - }) - - it("leaves a newer start screen draft with the same picture alone when a first send goes through", async () => { - let finishSend: (value: unknown) => void = () => {} - mockSidecar({ - 'projects.create': () => Promise.resolve({ id: '2026-11-06-a-clip', conversation: 4, title: 'A clip', createdMs: 3 }), - 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), - studio_import_file: () => Promise.resolve('refs/photo.jpg'), - }) - render() - await screen.findByText('Project B') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) - type('A clip') - fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - fireEvent.click(screen.getByTestId('new-project')) - type('A hook') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) - await new Promise((resolve) => setTimeout(resolve, 500)) - await act(async () => finishSend(null)) - expect(prompt().value).toBe('A hook') - expect(screen.getByTestId('attachments').textContent).toContain('photo.jpg') - const saves = sidecarCalls('drafts.set').filter((save) => save.key === 'new') - expect(saves[saves.length - 1]).toEqual({ key: 'new', text: 'A hook', attachments: ['/Users/me/photo.jpg'] }) + await waitFor(() => expect(sends()).toHaveLength(1)) + expect(sends()).toEqual([{ key: projectB.id, rev: draftRevs }]) }) it('keeps the composer and the saved draft the same when a picture was added during a send', async () => { - let finishSend: (value: unknown) => void = () => {} - mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)) }, [projectA, projectB]) + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') type('Make it red') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/new.jpg'] } }))) - await new Promise((resolve) => setTimeout(resolve, 500)) - await act(async () => finishSend(null)) + await waitFor(() => expect(sends()).toHaveLength(1)) + await drop(['/Users/me/new.jpg']) + await pause(500) + await send.release() const shown = { text: prompt().value, pictures: screen.getByTestId('attachments').textContent } fireEvent.click(screen.getByText('Project A')) fireEvent.click(screen.getByText('Project B')) @@ -552,32 +542,28 @@ describe('StudioApp', () => { }) it('clears a picture-only first message from the start screen the person went back to', async () => { - let finishSend: (value: unknown) => void = () => {} - mockSidecar({ - 'projects.create': () => Promise.resolve({ id: '2026-11-06-new-part', conversation: 4, title: 'New part', createdMs: 3 }), - 'chat.send': () => new Promise((resolve) => (finishSend = resolve)), - studio_import_file: () => Promise.resolve('refs/photo.jpg'), - }) + const send = held() + mockSidecar({ 'chat.send': send.call }) render() await screen.findByText('Project B') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/photo.jpg'] } }))) + await drop(['/Users/me/photo.jpg']) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + fireEvent.click(screen.getByTestId('new-project')) + await send.release() + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) fireEvent.click(screen.getByTestId('new-project')) - await act(async () => finishSend(null)) expect(screen.queryByTestId('attachments')).toBeNull() }) - it('sends a saved picture again under the same request id after its answer was lost and the project was left and opened again', async () => { - let sends = 0 - mockSidecar( - { - 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), - studio_import_file: () => Promise.resolve('refs/pic.jpg'), - }, - [projectA, projectB], - { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'], rev: 7 } }, - ) + // The cases of #205: each is a way a window that named its own messages sent one twice, took a new one for an old + // one, or lost or kept a draft by mistake. + + it('sends pictures restored by moving away and back as the same message: the same revision, with no new save', async () => { + const store = mockSidecar({ 'chat.send': lostOnce() }, [projectA, projectB], { + [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'], rev: 7 }, + }) render() fireEvent.click(await screen.findByText('Project A')) await waitFor(() => expect(prompt().value).toBe('A clip like this')) @@ -588,63 +574,53 @@ describe('StudioApp', () => { fireEvent.click(screen.getByText('Project A')) await waitFor(() => expect(prompt().value).toBe('A clip like this')) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) - const [first, second] = sidecarCalls('chat.send') - expect(second?.requestId).toBe(first?.requestId) + await waitFor(() => expect(sends()).toHaveLength(2)) + expect(sends()).toEqual([ + { key: projectA.id, rev: 7 }, + { key: projectA.id, rev: 7 }, + ]) + expect(saves()).toEqual([]) + expect(store.sent).toHaveLength(1) }) - it('sends a picture taken out and put back after a lost answer as a new message, as it may be a new file', async () => { - let sends = 0 - let imports = 0 - mockSidecar( - { - 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), - studio_import_file: () => Promise.resolve(`refs/pic-${++imports}.jpg`), - }, - [projectA, projectB], - { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'], rev: 7 } }, - ) + it('leaves no draft in the store when the person left the start screen before its first send finished', async () => { + const send = held() + const store = mockSidecar({ 'chat.send': send.call }) render() - fireEvent.click(await screen.findByText('Project A')) - await waitFor(() => expect(prompt().value).toBe('A clip like this')) - fireEvent.click(screen.getByTestId('send')) - await screen.findByRole('alert') - fireEvent.click(screen.getByLabelText('Remove pic.jpg')) - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/pic.jpg'] } }))) + await screen.findByText('Project B') + type('A clip') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) - const [first, second] = sidecarCalls('chat.send') - expect(second?.requestId).not.toBe(first?.requestId) - expect(second?.attachments).toEqual(['refs/pic-2.jpg']) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + await send.release() + await pause(500) + expect(store.drafts).toEqual({}) + expect(saves('new')).toEqual([{ key: 'new', text: 'A clip', attachments: [] }]) + fireEvent.click(screen.getByTestId('new-project')) + expect(prompt().value).toBe('') }) - it('sends a first message of only a picture again under the same request id from the project it made', async () => { - let sends = 0 - mockSidecar({ - 'projects.create': () => Promise.resolve({ id: '2026-11-06-new-part', conversation: 4, title: 'New part', createdMs: 3 }), - 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), - studio_import_file: () => Promise.resolve('refs/pic.jpg'), - }) + it('sends a picture taken out and put back as a new message, under a new revision', async () => { + mockSidecar({ 'chat.send': lostOnce() }, [projectA, projectB], { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'], rev: 7 } }) render() - await screen.findByText('Project B') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/pic.jpg'] } }))) + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip like this')) fireEvent.click(screen.getByTestId('send')) await screen.findByRole('alert') - await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) + fireEvent.click(screen.getByLabelText('Remove pic.jpg')) + await drop(['/Users/me/pic.jpg']) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) - const [first, second] = sidecarCalls('chat.send') - expect(second?.requestId).toBe(first?.requestId) + await waitFor(() => expect(sends()).toHaveLength(2)) + expect(last(saves())).toEqual({ key: projectA.id, text: 'A clip like this', attachments: ['/Users/me/pic.jpg'] }) + expect(sends()[1]).toEqual({ key: projectA.id, rev: draftRevs }) + expect(draftRevs).not.toBe(7) }) - it('sends text changed after a lost answer as a new message, even when the change could not be saved', async () => { - let sends = 0 - let saves = 0 + it('fails a send whose draft save fails, and sends nothing under the older revision', async () => { + let tries = 0 mockSidecar( - { - 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), - 'drafts.set': () => (++saves === 2 ? Promise.reject('The engine is starting.') : Promise.resolve({ rev: 100 + saves })), - }, + { 'chat.send': lostOnce(), 'drafts.set': (_, real) => (++tries === 2 || tries === 3 ? Promise.reject('The engine is starting.') : real()) }, [projectA, projectB], ) render() @@ -654,186 +630,171 @@ describe('StudioApp', () => { fireEvent.click(screen.getByTestId('send')) await screen.findByRole('alert') type('A clip for my shelf') - // The pause after typing saves the change, and that save fails. - await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(2)) - await new Promise((resolve) => setTimeout(resolve, 20)) + // The pause after typing saves the change, and that save fails. So does the one the send makes. + await waitFor(() => expect(saves()).toHaveLength(2)) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) - const [first, second] = sidecarCalls('chat.send') - expect(second).toMatchObject({ text: 'A clip for my shelf' }) - expect(second?.requestId).not.toBe(first?.requestId) + await waitFor(() => expect(saves()).toHaveLength(3)) + await pause(50) + expect(sends()).toHaveLength(1) + expect(screen.getByRole('alert').textContent).toContain('The engine is starting.') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(2)) + expect(last(saves())).toEqual({ key: projectA.id, text: 'A clip for my shelf', attachments: [] }) + expect(sends()[1]).toEqual({ key: projectA.id, rev: draftRevs }) }) - it('opens a first message\'s project with the draft the sidecar moved there, not a newer start draft', async () => { - let finishCreate: (project: unknown) => void = () => {} - let sends = 0 - let imports = 0 - mockSidecar({ - 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)), - 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), - studio_import_file: () => Promise.resolve(`refs/pic-${++imports}.jpg`), - }) + it('keeps a start draft changed while its first send runs as a newer draft, exactly as it is, and never sends it', async () => { + const send = held() + const store = mockSidecar({ 'chat.send': send.call }) render() await screen.findByText('Project B') - type('A knob') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/a.jpg'] } }))) + await drop(['/Users/me/photo.jpg']) + type('A clip') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) - const sentRev = draftRevs - // While the project is made, the person swaps the picture and leaves. - fireEvent.click(screen.getByLabelText('Remove a.jpg')) - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/b.jpg'] } }))) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('A hook') fireEvent.click(screen.getByText('Project B')) - const made = { id: '2026-11-06-a-knob', conversation: 4, title: 'A knob', createdMs: 3 } - await act(async () => finishCreate({ ...made, draft: { text: 'A knob', attachments: ['/Users/me/a.jpg'], rev: sentRev } })) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - fireEvent.click(await screen.findByText('A knob')) - await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('A knob')) - expect(screen.getByTestId('attachments').textContent).toContain('a.jpg') - expect(screen.getByTestId('attachments').textContent).not.toContain('b.jpg') - fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) - const [first, second] = sidecarCalls('chat.send') - expect(second?.requestId).toBe(first?.requestId) - // The start screen keeps the newer draft with b.jpg. + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + expect(saves('new')).toEqual([{ key: 'new', text: 'A clip', attachments: ['/Users/me/photo.jpg'] }]) + await send.release() + await waitFor(() => expect(store.drafts.new).toMatchObject({ text: 'A hook', attachments: ['/Users/me/photo.jpg'] })) + expect(store.drafts.new?.rev).toBeGreaterThan(Number(sends()[0]?.rev)) + expect(sends()).toHaveLength(1) + expect(store.sent.map((message) => message.text)).toEqual(['A clip']) + expect(Object.keys(store.drafts)).toEqual(['new']) fireEvent.click(screen.getByTestId('new-project')) - await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('b.jpg')) + expect(prompt().value).toBe('A hook') + expect(screen.getByTestId('attachments').textContent).toContain('photo.jpg') }) - it('sends a newer draft the sidecar moved to a first message\'s project as a new message', async () => { - let finishCreate: (project: unknown) => void = () => {} - let sends = 0 - let imports = 0 - const revs: number[] = [] - mockSidecar({ - 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)), - 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), - 'drafts.set': () => { - revs.push(++draftRevs) - return Promise.resolve({ rev: draftRevs }) - }, - studio_import_file: () => Promise.resolve(`refs/pic-${++imports}.jpg`), - }) + it('sends a picture-only first message again after a lost answer to the same project, once', async () => { + const store = mockSidecar({ 'chat.send': lostOnce() }) render() await screen.findByText('Project B') - type('A knob') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/a.jpg'] } }))) + await drop(['/Users/me/pic.jpg']) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) - fireEvent.click(screen.getByLabelText('Remove a.jpg')) - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/b.jpg'] } }))) + await screen.findByRole('alert') fireEvent.click(screen.getByText('Project B')) - await waitFor(() => expect(revs).toHaveLength(2)) - const made = { id: '2026-11-06-a-knob', conversation: 4, title: 'A knob', createdMs: 3 } - // The sidecar made the project after the newer draft was saved, so it took that one. - await act(async () => finishCreate({ ...made, draft: { text: 'A knob', attachments: ['/Users/me/b.jpg'], rev: revs[1] } })) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - fireEvent.click(await screen.findByText('A knob')) - await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('b.jpg')) + fireEvent.click(screen.getByTestId('new-project')) + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('pic.jpg')) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) - const [first, second] = sidecarCalls('chat.send') - expect(second?.requestId).not.toBe(first?.requestId) - expect(second?.attachments).toEqual(['refs/pic-2.jpg']) + await waitFor(() => expect(sends()).toHaveLength(2)) + const [first, second] = sends() + expect(second).toEqual(first) + expect(saves()).toHaveLength(1) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) + expect(store.projects.filter((p) => p.title === 'New part')).toHaveLength(1) + expect(store.sent).toHaveLength(1) }) - it('sends a picture-only first message again from the start screen into the same project under the same request id', async () => { - let sends = 0 - let made = 0 - mockSidecar({ - // Each call makes a project of its own, as the sidecar does. - 'projects.create': () => Promise.resolve({ id: `2026-11-06-new-part-${++made}`, conversation: 3 + made, title: 'New part', createdMs: 3 }), - 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)), - studio_import_file: () => Promise.resolve('refs/pic.jpg'), - }) + it('never saves one screen twice at once when Send comes while an autosave is on its way, and sends an unchanged message once', async () => { + const save = held() + const store = mockSidecar({ 'drafts.set': save.call }, [projectA, projectB]) render() - await screen.findByText('Project B') - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/pic.jpg'] } }))) + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + await waitFor(() => expect(saves()).toHaveLength(1)) + fireEvent.click(screen.getByTestId('send')) + await pause(50) + expect(saves()).toHaveLength(1) + await save.release() + await waitFor(() => expect(sends()).toEqual([{ key: projectB.id, rev: draftRevs }])) + await pause(500) + expect(saves()).toHaveLength(1) + expect(store.sent).toHaveLength(1) + }) + + it('sends pictures restored from a late saved state under a new revision, never as a repeat of a sent one', async () => { + let finishState: (state: unknown) => void = () => {} + const store = mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)), 'chat.send': lostOnce() }) + render() + type('a hook') + await pause(500) fireEvent.click(screen.getByTestId('send')) await screen.findByRole('alert') - fireEvent.click(screen.getByTestId('new-project')) - await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('pic.jpg')) + await act(async () => + finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'older', attachments: ['/Users/me/hook.jpg'], rev: 1 } }, importBlocked: null }), + ) + expect(screen.getByTestId('attachments').textContent).toContain('hook.jpg') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) - const [first, second] = sidecarCalls('chat.send') - expect(second?.project).toBe(first?.project) - expect(second?.requestId).toBe(first?.requestId) + await waitFor(() => expect(sends()).toHaveLength(2)) + const [first, second] = sends() + expect(second?.rev).not.toBe(first?.rev) + expect(last(saves('new'))).toEqual({ key: 'new', text: 'a hook', attachments: ['/Users/me/hook.jpg'] }) + expect(last(store.sent)).toMatchObject({ rev: second?.rev, attachments: ['/Users/me/hook.jpg'] }) }) - it('names the start draft a first send went out with, even when the person saved a newer one and left before the project was made', async () => { - let finishCreate: (project: unknown) => void = () => {} - const revs: number[] = [] - mockSidecar({ - 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)), - 'drafts.set': () => { - revs.push(++draftRevs) - return Promise.resolve({ rev: draftRevs }) - }, - }) - render() + it('takes a picture-only first message to its project and leaves the start screen empty, after a relaunch too', async () => { + const store = mockSidecar() + const first = render() await screen.findByText('Project B') - type('A knob') + await drop(['/Users/me/photo.jpg']) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) - const sent = revs[0] - type('Then a dial') - fireEvent.click(screen.getByText('Project B')) - await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(2)) - await act(async () => finishCreate({ id: '2026-11-06-a-knob', conversation: 4, title: 'A knob', createdMs: 3 })) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - expect(sidecarCalls('chat.send')[0]).toMatchObject({ text: 'A knob', draftRev: sent }) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) + expect(screen.queryByTestId('attachments')).toBeNull() + fireEvent.click(screen.getByTestId('new-project')) + expect(screen.queryByTestId('attachments')).toBeNull() + await pause(500) + expect(store.drafts).toEqual({}) + first.unmount() + + mockSidecar({}, store.projects, store.drafts) + render() + await screen.findByText('New part') + expect(screen.queryByTestId('attachments')).toBeNull() + expect(prompt().value).toBe('') }) it('waits for a draft save already on its way before it says a quit may go on', async () => { - let finishSave: (value: unknown) => void = () => {} - mockSidecar({ 'drafts.set': () => new Promise((resolve) => (finishSave = resolve)) }) + const save = held() + mockSidecar({ 'drafts.set': save.call }) render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') type('half a thought') // The pause after typing starts the save, which the sidecar has not answered yet. - await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(1)) + await waitFor(() => expect(saves()).toHaveLength(1)) await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) - await new Promise((resolve) => setTimeout(resolve, 50)) + await pause(50) const flushed = () => invokeMock.mock.calls.some(([command]) => command === 'studio_flushed') expect(flushed()).toBe(false) - await act(async () => finishSave({ rev: 1 })) + await save.release() await waitFor(() => expect(flushed()).toBe(true)) }) - it('keeps text typed while the draft before a send was still being saved', async () => { - let finishSave: (value: unknown) => void = () => {} - let saves = 0 - mockSidecar({ 'drafts.set': () => (++saves === 1 ? new Promise((resolve) => (finishSave = resolve)) : Promise.resolve({ rev: 100 + saves })) }) + it('keeps text typed while the draft before a send was still being saved, and saves it once the send is done', async () => { + let finishSave = () => {} + let tries = 0 + mockSidecar({ 'drafts.set': (_, real) => (++tries === 1 ? new Promise((resolve) => (finishSave = () => resolve(real()))) : real()) }) render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') type('Make it red') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(1)) + await waitFor(() => expect(saves()).toHaveLength(1)) type('Make it blue') - // The newer text's own save ends first, so only the send's first save names what went out. - await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(2)) - await act(async () => finishSave({ rev: 1 })) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - expect(sidecarCalls('chat.send')[0]).toMatchObject({ text: 'Make it red', draftRev: 1 }) - await new Promise((resolve) => setTimeout(resolve, 50)) + await pause(500) + expect(saves()).toHaveLength(1) + await act(async () => finishSave()) + await waitFor(() => expect(sends()).toEqual([{ key: projectB.id, rev: expect.any(Number) }])) + await waitFor(() => expect(last(saves())).toEqual({ key: projectB.id, text: 'Make it blue', attachments: [] })) expect(prompt().value).toBe('Make it blue') }) - it('keeps a picture added while the project of a first message was being made', async () => { - let finishCreate: (project: unknown) => void = () => {} - mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)), studio_import_file: () => Promise.resolve('refs/photo.jpg') }) + it("keeps a picture added while a first message was on its way, as its project's draft", async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }) render() await screen.findByText('Project B') type('A clip') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) - await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/later.jpg'] } }))) - await act(async () => finishCreate({ id: '2026-11-06-a-clip', conversation: 4, title: 'A clip', createdMs: 3 })) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - await new Promise((resolve) => setTimeout(resolve, 50)) + await waitFor(() => expect(sends()).toHaveLength(1)) + await drop(['/Users/me/later.jpg']) + await send.release() + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('A clip')) expect(screen.getByTestId('attachments').textContent).toContain('later.jpg') + await waitFor(() => expect(saves('2026-11-06-a-clip')).toEqual([{ key: '2026-11-06-a-clip', text: 'A clip', attachments: ['/Users/me/later.jpg'] }])) }) it('keeps chat changes that arrive while the chat opens', async () => { @@ -890,35 +851,35 @@ describe('StudioApp', () => { }) it("clears a sent project's saved draft even when the person typed in another project meanwhile", async () => { - let finishSend: (value: unknown) => void = () => {} - mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)) }, [projectA, projectB]) + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') type('Make it red') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) fireEvent.click(screen.getByText('Project A')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) type('something for A') - await act(async () => finishSend(null)) + await send.release() fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) expect(prompt().value).toBe('') }) it('keeps text typed during a send as the next message, even when it reads the same as the sent one', async () => { - let finishSend: (value: unknown) => void = () => {} - mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)) }) + const send = held() + mockSidecar({ 'chat.send': send.call }) render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') type('Make it red') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) type('Make it blue') type('Make it red') - await act(async () => finishSend(null)) + await send.release() expect(prompt().value).toBe('Make it red') }) @@ -943,20 +904,20 @@ describe('StudioApp', () => { await screen.findByRole('banner') type('make it blue') fireEvent.click(screen.getByText('Project A')) - expect(sidecarCalls('drafts.set')).toEqual([{ key: projectB.id, text: 'make it blue', attachments: [] }]) + expect(saves()).toEqual([{ key: projectB.id, text: 'make it blue', attachments: [] }]) }) it('keeps text typed while a send goes through as the draft', async () => { - let finishSend: (value: null) => void = () => {} - mockSidecar({ 'chat.send': () => new Promise((resolve) => (finishSend = resolve)) }, [projectA, projectB]) + const send = held() + mockSidecar({ 'chat.send': send.call }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project B')) await screen.findByRole('banner') type('make it red') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) type('make it blue') - await act(async () => finishSend(null)) + await send.release() fireEvent.click(screen.getByText('Project A')) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(prompt().value).toBe('make it blue')) @@ -967,99 +928,72 @@ describe('StudioApp', () => { mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) render() type('A clip for my desk') - await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'an older draft' } } })) + await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'an older draft', attachments: [], rev: 1 } } })) expect(prompt().value).toBe('A clip for my desk') }) it('leaves the start screen empty after its message was sent', async () => { - mockSidecar({ 'projects.create': () => Promise.resolve(projectA) }) + mockSidecar() render() await screen.findByText('Project B') type('A clip for my desk') - await waitFor(() => expect(sidecarCalls('drafts.set')).toEqual([{ key: 'new', text: 'A clip for my desk', attachments: [] }])) + await waitFor(() => expect(saves()).toEqual([{ key: 'new', text: 'A clip for my desk', attachments: [] }])) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) fireEvent.click(screen.getByTestId('new-project')) expect(prompt().value).toBe('') }) - it("keeps text typed while a new project is made as that project's draft", async () => { - let finishCreate: (project: unknown) => void = () => {} - mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) + it("keeps text typed during a first send as its project's draft, and never saves it on the start screen", async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }) render() await screen.findByText('Project B') type('A clip for my desk') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) type('second message') - // The pause saves it as the start screen's draft before the project is there. - await waitFor(() => expect(sidecarCalls('drafts.set')).toContainEqual({ key: 'new', text: 'second message', attachments: [] })) - await act(async () => finishCreate(projectA)) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - expect(sidecarCalls('drafts.set')).toContainEqual({ key: projectA.id, text: 'second message', attachments: [] }) - const saves = sidecarCalls('drafts.set') - expect(saves[saves.length - 1]).toEqual({ key: 'new', text: '', attachments: [] }) + await pause(500) + await send.release() + const made = '2026-11-06-a-clip-for-my-desk' + await waitFor(() => expect(saves(made)).toEqual([{ key: made, text: 'second message', attachments: [] }])) + expect(saves('new')).toEqual([{ key: 'new', text: 'A clip for my desk', attachments: [] }]) fireEvent.click(screen.getByText('Project B')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) - fireEvent.click(screen.getByText('Project A')) + fireEvent.click(screen.getByText('A clip for my desk')) await waitFor(() => expect(prompt().value).toBe('second message')) }) - it('never puts a sent message back on the start screen when text typed while the project was made was taken back', async () => { - let finishCreate: (project: unknown) => void = () => {} - mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) + it('never puts a sent message back on the start screen when text typed during it was taken back', async () => { + const send = held() + mockSidecar({ 'chat.send': send.call }) render() await screen.findByText('Project B') type('A clip for my desk') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) + await waitFor(() => expect(sends()).toHaveLength(1)) type('A clip for my desk, and') type('A clip for my desk') - await act(async () => finishCreate(projectA)) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - await new Promise((resolve) => setTimeout(resolve, 600)) - const restored = sidecarCalls('drafts.set').filter((save) => save.key === 'new' && save.text === 'A clip for my desk') - expect(restored).toHaveLength(1) + await send.release() + await pause(600) + expect(saves('new')).toEqual([{ key: 'new', text: 'A clip for my desk', attachments: [] }]) fireEvent.click(screen.getByTestId('new-project')) expect(prompt().value).toBe('') }) - it('leaves a start screen draft typed after leaving and coming back alone when the earlier project is made', async () => { - let finishCreate: (project: unknown) => void = () => {} - mockSidecar({ 'projects.create': () => new Promise((resolve) => (finishCreate = resolve)) }) - render() - await screen.findByText('Project B') - type('A clip for my desk') - fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('projects.create')).toHaveLength(1)) - fireEvent.click(screen.getByText('Project B')) - await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) - fireEvent.click(screen.getByTestId('new-project')) - type('A shelf bracket') - await act(async () => finishCreate(projectA)) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(1)) - expect(prompt().value).toBe('A shelf bracket') - // Once typing pauses, the bracket is the start screen's draft, never the new project's. - await waitFor(() => expect(sidecarCalls('drafts.set')).toContainEqual({ key: 'new', text: 'A shelf bracket', attachments: [] })) - expect(sidecarCalls('drafts.set')).not.toContainEqual(expect.objectContaining({ key: projectA.id, text: 'A shelf bracket' })) - }) - it('leaves the start screen empty when the person cleared it before the saved state arrived', async () => { let finishState: (state: unknown) => void = () => {} mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)) }) render() type('A clip for my desk') type('') - await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'an older draft' } } })) + await act(async () => finishState({ setup: { step: 'ready', done: true }, settings, projects: [projectB], drafts: { new: { text: 'an older draft', attachments: [], rev: 1 } } })) expect(prompt().value).toBe('') }) - it("keeps each project's unsent message, so sending it again after another project's send reuses its request id", async () => { - let sends = 0 - mockSidecar( - { 'chat.send': () => (sends++ === 0 ? Promise.reject('The engine restarted before it answered. Try again.') : Promise.resolve(null)) }, - [projectA, projectB], - ) + it("keeps each project's unsent message, so sending it again after another project's send names its revision", async () => { + let tries = 0 + mockSidecar({ 'chat.send': (_, real) => (++tries === 1 ? Promise.reject(LOST) : real()) }, [projectA, projectB]) render() fireEvent.click(await screen.findByText('Project A')) await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) @@ -1070,14 +1004,14 @@ describe('StudioApp', () => { await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) type('make it blue') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(2)) + await waitFor(() => expect(sends()).toHaveLength(2)) fireEvent.click(screen.getByText('Project A')) - await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) - type('A clip for my desk') + await waitFor(() => expect(prompt().value).toBe('A clip for my desk')) fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sidecarCalls('chat.send')).toHaveLength(3)) - const [first, , third] = sidecarCalls('chat.send') - expect(third?.requestId).toBe(first?.requestId) + await waitFor(() => expect(sends()).toHaveLength(3)) + const [first, , third] = sends() + expect(third).toEqual(first) + expect(saves(projectA.id)).toHaveLength(1) }) it('shows the code a device sign-in asks for, with its page', async () => { diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 4ca54cda..03fd6a2e 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -1,9 +1,9 @@ import { getCurrentWebview } from '@tauri-apps/api/webview' import { openUrl } from '@tauri-apps/plugin-opener' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { AppState, Draft, LoginEvent, Need, ChatView as View, Project } from '../../sidecar/src/protocol' +import { type AppState, type Draft, type LoginEvent, type Need, type Project, START, type ChatView as View } from '../../sidecar/src/protocol' import { autoOpens, onQuitFlush, onSidecarEvent, sidecar, studio, type ProjectFile } from './api' -import { ChatView, Composer, releaseAttachment, savedAttachment, type Attachment, type PastedPicture } from './Chat' +import { ChatView, Composer, releaseAttachment, savedAttachment, type PastedPicture } from './Chat' import RightPanel, { type Tab } from './RightPanel' import { buildChat } from './transcript' import { ImportBlockedBanner, ReadinessBanner, SignInAsk, WaitingBanner } from './Waiting' @@ -19,12 +19,28 @@ const SUGGESTIONS = [ const PICTURE = /\.(png|jpe?g|heic|heif|webp|gif)$/i -/** The unsent message of the start screen, before a project exists. */ -const START = 'new' - /** How long typing pauses before the draft is saved. */ const DRAFT_PAUSE_MS = 400 +const EMPTY: Draft = { text: '', attachments: [] } + +/** + * A screen's unsent message as the window holds it. The sidecar owns the draft and the send: the window only knows + * whether the screen changed since its last completed save, and that save's revision, which a send names. + */ +type Screen = { + draft: Draft + /** The last save that completed: the draft it saved, and its revision. None while the store holds no draft. */ + saved?: { draft: Draft; rev: number } + /** The save on its way. A screen has one at a time. */ + saving?: Promise + /** The send on its way. Nothing else saves the screen meanwhile, as that would replace the draft it sends. */ + sending?: Promise +} + +/** Whether the screen changed since its last completed save. Each change makes a new draft object. */ +const changed = (screen: Screen) => screen.draft !== (screen.saved?.draft ?? EMPTY) + /** * A chat with a change applied. Entries only grow, so each one is kept. The running state comes from whichever was read * last, so a change read before the open chat, which can arrive after it, never puts back a turn that already ended. @@ -55,12 +71,11 @@ export default function StudioApp() { const [files, setFiles] = useState([]) const [selected, setSelected] = useState(null) const [tab, setTab] = useState('preview') - const [attachments, setAttachments] = useState([]) const [error, setError] = useState(null) /** A failed send's error, kept with the project it was sent to and shown only there. */ const [sendErrors, setSendErrors] = useState>({}) - /** The unsent message. The sidecar keeps it as the open project's draft. */ - const [text, setText] = useState('') + /** The open screen's draft, as its composer shows it. */ + const [shown, setShown] = useState(EMPTY) const [sending, setSending] = useState(false) const [engine, setEngine] = useState('Engine starting') /** What a sign-in needs from the person: the code to enter on its page, or an answer. */ @@ -76,76 +91,107 @@ export default function StudioApp() { /** The newest file time already shown, so only newer files take over the preview. */ const lastSeen = useRef(0) /** - * Drafts by project, or `new` for the start screen, to fill the composer when a screen opens. The rules: typing saves - * the draft of its screen once typing pauses, or at once when the person leaves the screen or sends. A send removes - * the draft only when it holds the sent text. Making a project moves the start screen's draft to it. A draft keeps - * its pictures too, as the paths of their files. + * Each screen the window changed or filled from the saved state, by project id, or `START` for the start screen. + * Typing saves its screen once typing pauses, and at once when the person leaves it. A send saves first if the + * screen changed, then names the revision. A draft keeps its pictures as the paths of their files. */ - const drafts = useRef>({}) - /** A draft not saved yet, and the timer that saves it. */ - const unsaved = useRef<{ key: string; draft: Draft; timer: number } | null>(null) - /** The pictures a send imported into its project, by the draft revision it went out as, for a send of it again. */ - const imported = useRef(new Map()) - /** Whether the person typed on the start screen, so the saved draft no longer fills it. */ - const typedOnStart = useRef(false) + const screens = useRef>({}) + /** The open screen's save that waits for typing to pause. */ + const pending = useRef<{ key: string; timer: number } | null>(null) + /** The previews of pasted pictures, by path, freed when the window closes. */ + const previews = useRef(new Map()) /** Whether the person changed the start screen's pictures, so its saved ones no longer fill it. */ const startPicturesTouched = useRef(false) - /** The composer's text as it is now, for a send that finishes after the person typed more. */ - const composerText = useRef('') /** The project whose chat view is loaded. Chat changes for the open project wait in `early` until it is. */ const loaded = useRef(null) - /** Counts the changes to each screen's draft, so a save knows whether the draft changed while it was on its way. */ - const edits = useRef>({}) - /** - * The revision the sidecar gave each screen's draft as it is now, or none when the draft changed since its last save. - * It is the message's identity: a send goes out as `draft-`, so a send of the same draft again, as after a - * lost answer, is the same message, and the sidecar sends it once. Any change is a new message. - */ - const revs = useRef>({}) - /** The last save the window started for each screen's draft, and the revision it got, or none if it failed. */ - const saving = useRef>>({}) const early = useRef([]) /** Counts chat loads, so only the newest load of the open chat replaces its view, not an older one that answers late. */ const loads = useRef(0) - composerText.current = text const items = useMemo(() => buildChat(view?.entries ?? [], view?.running ?? true), [view?.entries, view?.running]) const running = view?.running ?? false + const attachments = shown.attachments.map((path) => ({ ...savedAttachment(path), previewUrl: previews.current.get(path) })) useUpdateEvents() useEffect(() => { void checkForUpdatesAtLaunch() }, []) + const openKey = () => currentId.current ?? START + const draftOf = (key: string) => screens.current[key]?.draft ?? EMPTY + + /** Puts a draft on a screen, and in the composer if that screen is open. */ + const put = (key: string, draft: Draft) => { + ;(screens.current[key] ??= { draft }).draft = draft + if (key === openKey()) setShown(draft) + } + + /** Empties a screen whose draft the store no longer holds. */ + const clear = (key: string) => { + put(key, EMPTY) + delete screens.current[key].saved + } + + /** + * Saves a screen until the store holds what it shows, one save at a time: a save wanted while one runs waits for it, + * then saves the newest draft. While the screen's send is on its way, only that send saves it. Answers the last + * completed save. + */ + const flush = async (key: string, forSend = false) => { + const screen = screens.current[key] + if (screen === undefined) return undefined + while (screen.saving !== undefined) await screen.saving + if ((screen.sending === undefined || forSend) && changed(screen)) { + const draft = screen.draft + const save = sidecar('drafts.set', { key, ...draft }).then(({ rev }) => { + screen.saved = { draft, rev } + }) + const done = () => { + screen.saving = undefined + } + screen.saving = save.then(done, done) + await save + } + return screen.saved + } + + const autosave = (key: string) => void flush(key).catch((err: unknown) => setError(String(err))) + + const clearPause = () => { + if (pending.current !== null) window.clearTimeout(pending.current.timer) + pending.current = null + } + + /** Changes the open screen's draft from its composer, and saves it once typing pauses. */ + const edit = (draft: Draft) => { + const key = openKey() + put(key, draft) + clearPause() + const timer = window.setTimeout(() => { + pending.current = null + autosave(key) + }, DRAFT_PAUSE_MS) + pending.current = { key, timer } + } + /** Takes the app's saved state: projects, drafts, and whether the old chats could be read. */ const takeState = (state: AppState) => { - // Drafts saved in this window since are newer, and text already typed stays. The start screen's saved pictures - // still come back when the person only typed there, even if that typing was saved. - const saved = state.drafts[START] - const local = drafts.current[START] - const restoreStart = !startPicturesTouched.current && saved !== undefined && saved.attachments.length > 0 && (local?.attachments.length ?? 0) === 0 - // A saved revision names a draft only where this window has not changed it. - for (const [key, draft] of Object.entries(state.drafts)) if (!(key in drafts.current) && !edits.current[key]) revs.current[key] = draft.rev - drafts.current = { ...state.drafts, ...drafts.current } - if (restoreStart) drafts.current = { ...drafts.current, [START]: { text: local?.text ?? saved.text, attachments: saved.attachments } } + // The saved state fills only the screens this window has not changed, since what it changed is newer. + for (const [key, saved] of Object.entries(state.drafts)) { + const local = screens.current[key] + if (local === undefined) { + const draft: Draft = { text: saved.text, attachments: saved.attachments } + screens.current[key] = { draft, saved: { draft, rev: saved.rev } } + if (key === openKey()) setShown(draft) + } else if (key === START && !startPicturesTouched.current && local.draft.attachments.length === 0 && saved.attachments.length > 0) { + // The person only typed here before the saved state came, so its pictures come back. That changes the draft. + put(START, { ...local.draft, attachments: saved.attachments }) + autosave(START) + } + } // A project made while this was on its way is newer than the saved list, so it stays. setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) setImportBlocked(state.importBlocked) - if (currentId.current === null) { - const draft = drafts.current[START] - if (!typedOnStart.current) setText(draft?.text ?? '') - // Pictures the person has not touched yet come back even when they typed first. A save that waits for that typing - // then keeps them too. - const restored = (draft?.attachments ?? []).map(savedAttachment) - if (unsent.current.length === 0 && restored.length > 0) { - // Typing already saved, or waiting to be, gets the pictures too. - if (typedOnStart.current) setPictures(restored) - else { - unsent.current = restored - setAttachments(restored) - } - } - } } /** Asks the sidecar what the agent still needs before a send. */ @@ -225,102 +271,54 @@ export default function StudioApp() { } }, []) - /** Saves one screen's draft, and keeps the revision the sidecar gave it if the draft did not change meanwhile. */ - const storeDraft = (key: string, draft: Draft) => { - const at = edits.current[key] ?? 0 - const save = sidecar('drafts.set', { key, ...draft }).then(({ rev }) => { - if ((edits.current[key] ?? 0) === at) revs.current[key] = Math.max(revs.current[key] ?? 0, rev) - return rev - }) - saving.current[key] = save.catch(() => undefined) - return save - } - - /** Counts a change to a screen's draft. Its revision no longer names it. */ - const changed = (key: string) => { - edits.current[key] = (edits.current[key] ?? 0) + 1 - delete revs.current[key] - } - - /** Changes a screen's draft from outside its composer, and saves it. */ - const putDraft = (key: string, draft: Draft) => { - changed(key) - drafts.current = { ...drafts.current, [key]: draft } - void storeDraft(key, draft).catch((err: unknown) => setError(String(err))) - } - - /** Saves the draft now, if one waits to be saved. */ - const saveDraft = async () => { - const waiting = unsaved.current - if (waiting === null) return - window.clearTimeout(waiting.timer) - unsaved.current = null - drafts.current = { ...drafts.current, [waiting.key]: waiting.draft } - await storeDraft(waiting.key, waiting.draft) - } + useEffect(() => () => previews.current.forEach((url) => URL.revokeObjectURL(url)), []) + useEffect(() => () => window.clearTimeout(pending.current?.timer), []) - // The composer's pictures as they are now, for a draft saved from outside a render, and to free their previews. - const unsent = useRef(attachments) - unsent.current = attachments - useEffect(() => () => unsent.current.forEach(releaseAttachment), []) + const type = (text: string) => edit({ ...draftOf(openKey()), text }) - /** Saves the open screen's draft once typing pauses: its text and its pictures. */ - const queueDraft = (text: string, pictures: Attachment[]) => { - if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) - const timer = window.setTimeout(() => void saveDraft().catch((err: unknown) => setError(String(err))), DRAFT_PAUSE_MS) - unsaved.current = { key: currentId.current ?? START, draft: { text, attachments: pictures.map((a) => a.path) }, timer } - } - - /** Counts an edit to the open screen's composer. */ - const edited = () => changed(currentId.current ?? START) - - const type = (value: string) => { - edited() - setText(value) - queueDraft(value, unsent.current) - if (currentId.current === null) typedOnStart.current = true - } - useEffect(() => () => window.clearTimeout(unsaved.current?.timer), []) - - /** Changes the composer's pictures, and saves them with the draft. */ - const setPictures = (next: Attachment[]) => { - if (currentId.current === null) startPicturesTouched.current = true - edited() - unsent.current = next - setAttachments(next) - queueDraft(composerText.current, next) + /** Changes the open screen's pictures, and saves them with its draft. */ + const setPictures = (paths: string[]) => { + if (openKey() === START) startPicturesTouched.current = true + edit({ ...draftOf(openKey()), attachments: paths }) } /** * Keeps pasted pictures in files, then adds them to the screen they were pasted on: to its composer if the person is - * there, or else to its saved draft. + * there, or else to its draft, saved as any change is. */ const pastePictures = (pasted: PastedPicture[]) => { - const key = currentId.current ?? START + const key = openKey() void Promise.all( pasted.map(async ({ name, file }) => ({ - name, path: await sidecar('drafts.picture', { name, data: toBase64(new Uint8Array(await file.arrayBuffer())) }), file, })), ) .then((kept) => { - if ((currentId.current ?? START) === key) { - setPictures([...unsent.current, ...kept.map(({ name, path, file }) => ({ name, path, previewUrl: URL.createObjectURL(file) }))]) + for (const { path, file } of kept) previews.current.set(path, URL.createObjectURL(file)) + const paths = [...draftOf(key).attachments, ...kept.map(({ path }) => path)] + if (openKey() === key) { + setPictures(paths) return } - const draft = drafts.current[key] ?? { text: '', attachments: [] } - putDraft(key, { ...draft, attachments: [...draft.attachments, ...kept.map(({ path }) => path)] }) + if (key === START) startPicturesTouched.current = true + put(key, { ...draftOf(key), attachments: paths }) + autosave(key) }) .catch((err: unknown) => setError(String(err))) } - // A quit or the window's close first saves the draft still waiting, and waits for every save already on its way, - // then lets the quit go on. + // A quit or the window's close waits for each send and save on its way, saves every changed screen, then lets the + // quit go on. useEffect(() => { const unlisten = onQuitFlush(async () => { - await saveDraft() - await Promise.all(Object.values(saving.current)) + clearPause() + await Promise.all( + Object.entries(screens.current).map(async ([key, screen]) => { + await screen.sending + await flush(key) + }), + ) }) return () => { void unlisten.then((stop) => stop()) @@ -336,7 +334,7 @@ export default function StudioApp() { setError('Drop a picture: PNG, JPG, HEIC, WebP, or GIF.') return } - setPictures([...unsent.current, ...pictures.map(savedAttachment)]) + setPictures([...draftOf(openKey()).attachments, ...pictures]) }) return () => { void unlisten.then((stop) => stop()) @@ -366,17 +364,13 @@ export default function StudioApp() { return () => window.clearInterval(timer) }, [current, running, refreshFiles]) - /** Shows a project, or the start screen, with its saved draft. */ - const show = (project: Project | null, keepText = false) => { - void saveDraft().catch((err: unknown) => setError(String(err))) - if (!keepText) { - const draft = drafts.current[project?.id ?? START] - setText(draft?.text ?? '') - attachments.forEach(releaseAttachment) - setAttachments((draft?.attachments ?? []).map(savedAttachment)) - } + /** Shows a project, or the start screen, with its draft. The screen left is saved at once. */ + const show = (project: Project | null) => { + clearPause() + autosave(openKey()) loadRequest.current += 1 currentId.current = project?.id ?? null + setShown(draftOf(openKey())) loaded.current = null early.current = [] lastSeen.current = 0 @@ -408,112 +402,51 @@ export default function StudioApp() { } } + /** + * Sends the open screen's draft by its revision: saved first if it changed, and a save that fails ends the send. The + * sidecar makes the project for a first message, copies the pictures, and removes the draft it sent. + */ const send = async () => { - const message = text.trim() - if ((!message && attachments.length === 0) || sendingNow.current || running || notReady) return + const key = openKey() + const screen = screens.current[key] + if (screen === undefined || (screen.draft.text.trim() === '' && screen.draft.attachments.length === 0) || sendingNow.current || running || notReady) return sendingNow.current = true setSending(true) - // The view this send belongs to. A move to another project or the start screen - // while it runs changes `loadRequest`, and then the send leaves that view alone. - let request = loadRequest.current - let sentTo: string | null = null - const empty: Draft = { text: '', attachments: [] } + clearPause() + let done = () => {} + screen.sending = new Promise((resolve) => (done = resolve)) try { - // The draft this send goes out as, named by its revision: the one the sidecar gave the draft as it is now, or else - // the one a save of it gives now. Both are taken before the person can change it. A save that fails ends the send. - const fromKey = current?.id ?? START - const goingOut: Draft = { text, attachments: attachments.map((a) => a.path) } - const waiting = unsaved.current?.key === fromKey ? unsaved.current : null - if (waiting !== null) { - window.clearTimeout(waiting.timer) - unsaved.current = null - drafts.current = { ...drafts.current, [fromKey]: waiting.draft } + const sent = await flush(key, true) + if (sent === undefined) return + const { project } = await sidecar('chat.send', { key, rev: sent.rev }) + setSendErrors(({ [project.id]: _, ...rest }) => rest) + setProjects((prev) => (prev.some((p) => p.id === project.id) ? prev : [project, ...prev])) + // What changed since the save that went out is the next message. Only the pictures that went out leave it. + const outgoing = new Set(sent.draft.attachments) + const next: Draft = { ...screen.draft, attachments: screen.draft.attachments.filter((path) => !outgoing.has(path)) } + const unchanged = screen.draft === sent.draft + if (key === START && openKey() === START) { + // The person is still where the first message went from, so they go to its project, and what they changed + // here meanwhile goes with them as its draft. + clear(START) + if (!unchanged) put(project.id, next) + void openProject(project) + autosave(project.id) + } else if (unchanged) { + clear(key) + } else if (key !== START && next.attachments.length !== screen.draft.attachments.length) { + put(key, next) } - const known = waiting === null ? revs.current[fromKey] : undefined - const rev = known ?? (await storeDraft(fromKey, goingOut)) - const createdHere = current === null - let project = current - if (!project) { - // One project per first message: sent again, the same revision finds the project it made. - const { draft: took, ...created } = await sidecar('projects.create', { title: message || 'New part', rev }) - project = created - // The project holds the start screen's draft only if the sidecar says it took it, and then the start screen's - // saved draft is gone, unless the person changed it since. A composer still showing it empties once the send - // goes through. - if (took !== null) { - const { rev: tookRev, ...draft } = took - drafts.current = { ...drafts.current, [created.id]: draft } - revs.current[created.id] = tookRev - if (revs.current[START] === tookRev) drafts.current = { ...drafts.current, [START]: empty } - } - setProjects((prev) => (prev.some((p) => p.id === created.id) ? prev : [created, ...prev])) - if (loadRequest.current === request) { - // The person stayed on the screen this was sent from, so its composer goes with them to the project: as - // what went out, or as a newer draft if they changed it while the project was made. A save still waiting - // belongs to that screen, so it goes too. - if (unsaved.current !== null) window.clearTimeout(unsaved.current.timer) - unsaved.current = null - const now: Draft = { text: composerText.current, attachments: unsent.current.map((a) => a.path) } - if (revs.current[START] === rev && took === null) { - drafts.current = { ...drafts.current, [created.id]: now } - revs.current[created.id] = rev - } else if (revs.current[START] !== rev) { - putDraft(created.id, now) - putDraft(START, empty) - } - show(created, true) - request = loadRequest.current - await load(created.id, request) - } - } - const id = project.id - sentTo = id - const outgoing = attachments - const images = imported.current.get(rev) ?? (await Promise.all(outgoing.map((a) => studio.importFile(id, a.path)))) - imported.current.set(rev, images) - // The sidecar removes the project's draft only if it is still this one, so a newer draft stays. - await sidecar('chat.send', { project: id, requestId: `draft-${rev}`, text: message, attachments: images, draftRev: rev }) - imported.current.delete(rev) - setSendErrors(({ [id]: _, ...rest }) => rest) - // Each screen that still holds what went out is empty again, as its saved draft is. The project's newer draft keeps - // all but the pictures that went out. A newer start screen draft is the person's next idea, and stays as it is. - const sent = new Set(outgoing.map((a) => a.path)) - for (const key of createdHere ? [id, START] : [id]) { - if (revs.current[key] !== rev && key === START) continue - if (revs.current[key] === rev) { - drafts.current = { ...drafts.current, [key]: empty } - delete revs.current[key] - // The start screen's draft stays saved when the sidecar did not take it, as a first message of only pictures. - if (key === START) void storeDraft(START, empty).catch((err: unknown) => setError(String(err))) - if ((currentId.current ?? START) === key) { - setText('') - unsent.current = [] - setAttachments([]) - } - continue - } - const keep = (draft: Draft): Draft => ({ ...draft, attachments: draft.attachments.filter((path) => !sent.has(path)) }) - if (unsaved.current?.key === key) unsaved.current.draft = keep(unsaved.current.draft) - const cached = drafts.current[key] - if (cached !== undefined && cached.attachments.some((path) => sent.has(path)) && unsaved.current?.key !== key) putDraft(key, keep(cached)) - if ((currentId.current ?? START) === key) { - unsent.current = unsent.current.filter((a) => !sent.has(a.path)) - setAttachments(unsent.current) - } - } - outgoing.forEach(releaseAttachment) } catch (err) { const message = String(err) - if (sentTo !== null) { - const project = sentTo - setSendErrors((prev) => ({ ...prev, [project]: message })) - } else if (loadRequest.current === request) { - // No project was made, so the error belongs to the start screen, if it is still open. - setError(message) - } + if (key !== START) setSendErrors((prev) => ({ ...prev, [key]: message })) + else if (openKey() === START) setError(message) } finally { + delete screen.sending + done() sendingNow.current = false setSending(false) + autosave(key) } } @@ -529,14 +462,15 @@ export default function StudioApp() { <> {needs !== null && } setPictures([...unsent.current, ...more])} + onAttach={(more) => setPictures([...draftOf(openKey()).attachments, ...more.map((a) => a.path)])} onPaste={pastePictures} onRemove={(index) => { releaseAttachment(attachments[index]) - setPictures(unsent.current.filter((_, i) => i !== index)) + previews.current.delete(shown.attachments[index]) + setPictures(draftOf(openKey()).attachments.filter((_, i) => i !== index)) }} onSend={() => void send()} onStop={current ? () => void sidecar('chat.stop', { project: current.id }).catch((err: unknown) => setError(String(err))) : undefined} From b4c9bc2eefaa494a788bd0a8012d19c6a1be9eff Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 17:36:04 -0600 Subject: [PATCH 53/59] chore(studio): drop the window's picture import call, which no send uses now The sidecar copies a message's pictures into its project. The Rust studio_import_file command stays for now, since removing it means regenerating the Tauri permission files. Co-Authored-By: Claude Opus 5.5 --- src/studio/api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/studio/api.ts b/src/studio/api.ts index 2f988659..3ae92c76 100644 --- a/src/studio/api.ts +++ b/src/studio/api.ts @@ -25,7 +25,6 @@ export interface ProjectFile { /** The project folder's own files: the native edges Rust keeps. */ export const studio = { files: (project: string) => invoke('studio_files', { project }), - importFile: (project: string, source: string) => invoke('studio_import_file', { project, source }), saveBytes: (project: string, name: string, bytes: Uint8Array) => invoke('studio_save_bytes', bytes, { headers: { 'm3d-project': project, 'm3d-name': encodeURIComponent(name) } }), reveal: (project: string, rel: string) => invoke('studio_reveal', { project, rel }), From 964383e2c4b9847cb3efaf5e4fc3ba5b89407d22 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 17:56:20 -0600 Subject: [PATCH 54/59] fix(studio): a cut-off first send goes on, a late saved state never brings a sent draft back, and a quit saves before it waits From Sol's review of b4c9bc2, each with a test that failed first. - A start screen revision's project keeps the draft it moved there. A send of that revision again, as after a crash before the message, sends that draft and leaves a newer project draft alone, so the start screen never gets stuck on "The draft changed". - The window keeps the newest revision sent from or to each screen. A saved state read before that send passes over drafts at or below it, so a sent picture never comes back. - A quit saves what each composer holds first, even during a send, then waits for the send, as Rust holds a quit for 1.5 s at most. The models' hash of chat.ts is recorded again. TLC: every verdict as recorded. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 2 +- sidecar/src/chat.ts | 7 ++- sidecar/src/store.ts | 24 ++++++---- sidecar/test/send.test.ts | 30 ++++++++++++ src/studio/StudioApp.test.tsx | 48 +++++++++++++++++++ src/studio/StudioApp.tsx | 14 +++++- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 3770e4a0..b158be93 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts -b97db6f1b7c07228dd20289e92f3acdbc11d34c0441ea7894c62908fb6a458ec sidecar/src/chat.ts +38c590f8dce792e7aa02b63188f88473c29d2b2001589055689c51d54aec10e4 sidecar/src/chat.ts 6d4eb78b893da94d0e56e02ea465b790981ae7d64fcce361d15ea42fea3c17d4 sidecar/src/app.ts diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index e71ed3be..0817d861 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -250,14 +250,17 @@ export class Chats { */ async send(key: string, rev: number): Promise { const { store, extension, tools, version, busy } = this.#o - const project = key === START ? await store.startProject(rev) : await store.project(key) + // A start screen send goes out as the draft its project took, even if the project's draft changed since, as after a + // crash before the message. A project's own send goes out as its draft, only while that is still this revision. + const started = key === START ? await store.startProject(rev) : undefined + const project = started?.project ?? (await store.project(key)) const conversation = await this.#conversation(project.id) const requestId = `draft-${rev}` if ((await store.harness.commit((tx) => tx.submissionByRequest(conversation.id, requestId), context)) !== undefined) { await this.#watch(project.id, conversation) return project } - const draft = await store.draft(project.id) + const draft = started?.draft ?? (await store.draft(project.id)) if (draft?.rev !== rev) throw new Error(DRAFT_CHANGED) const folder = store.projectFolder(project.id) const attachments = copyPictures(folder, rev, draft.attachments) diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 147c929a..a4e92606 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -47,8 +47,12 @@ export const SettingsDoc = defineDoc({ checkpointWhen: always, }) -/** The projects, and the project each start screen draft revision made, so a send of it again makes no other. */ -const ProjectsDoc = defineDoc<{ projects: Project[]; madeFor?: Record }>({ +/** + * The projects, and for each start screen draft revision that made one, that project and the draft it moved there, so + * a send of that revision again makes no other project and still sends what went out, even after the project's draft + * changed. + */ +const ProjectsDoc = defineDoc<{ projects: Project[]; madeFor?: Record }>({ kind: 'm3d.projects', version: 1, scope: 'conversation', @@ -245,18 +249,19 @@ export class Store implements LoginIndex { * draft's first line, and moves that draft to it, in one commit. Only the draft saved as `rev` moves, so a newer * start screen draft is never sent in its place. The folder is made inside that commit, before anything is kept, so a * folder that cannot be made keeps nothing. A crash after the folder leaves an empty folder, which the next id skips. - * Asked again for the same revision, as after a lost answer, it answers the project that revision made and changes - * nothing. + * Asked again for the same revision, as after a lost answer or a crash before the message, it answers the project + * that revision made and the draft it moved, and changes nothing. */ - async startProject(rev: number): Promise { + async startProject(rev: number): Promise<{ project: Project; draft: SavedDraft }> { const now = new Date() // The person's own date, as the folder name shows it. const today = new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10) return this.harness.commit(async (tx) => { const index = await tx.doc(ProjectsDoc, this.root.id) - const made = index.projects.find((p) => p.id === index.madeFor?.[rev]) + const before = index.madeFor?.[rev] + const made = index.projects.find((p) => p.id === before?.project) // A plain copy, since the documents a commit hands out are only valid inside it. - if (made !== undefined) return { ...made } + if (before !== undefined && made !== undefined) return JSON.parse(JSON.stringify({ project: made, draft: before.draft })) const { drafts } = await tx.doc(DraftsDoc, this.root.id) const draft = drafts[START] if (draft?.rev !== rev) throw new Error(DRAFT_CHANGED) @@ -270,10 +275,11 @@ export class Store implements LoginIndex { const conversation = await tx.createConversation({ ownership: { kind: 'ownerless' } }) const created: Project = { id, conversation: conversation.id, title, createdMs: now.getTime() } index.projects.push(created) - index.madeFor = { ...index.madeFor, [rev]: id } + const moved: SavedDraft = { text: draft.text, attachments: [...draft.attachments], rev } + index.madeFor = { ...index.madeFor, [rev]: { project: id, draft: moved } } drafts[id] = draft delete drafts[START] - return { ...created } + return JSON.parse(JSON.stringify({ project: created, draft: moved })) }, context) } diff --git a/sidecar/test/send.test.ts b/sidecar/test/send.test.ts index 63f4f737..b04cc33e 100644 --- a/sidecar/test/send.test.ts +++ b/sidecar/test/send.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from 'bun:test' import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createModels, InMemoryCredentialStore } from '@earendil-works/pi-ai' +import { createRegistry } from '@earendil-works/pi-durable' import type { ChatEntry, ChatView, SavedDraft, StoredState } from '../src/protocol' +import { Store } from '../src/store' import { spawnSidecar } from './sidecar' type App = ReturnType @@ -39,6 +42,33 @@ async function answered(app: App, project: string, text: string) { } describe('a send from the start screen', () => { + test('cut off after its project was made, goes on with what it went out as, and leaves a newer project draft alone', async () => { + const where = scratch() + const first = await start(where) + const rev = await save(first, 'new', 'A knob') + await first.quit() + // The sidecar stopped right after it made the project and moved the draft there, before the message. + const store = await Store.open({ + dataDir: where.data, + projectsDir: where.projects, + models: createModels({ credentials: new InMemoryCredentialStore() }), + registry: createRegistry(), + env: () => { + throw new Error('no conversation runs here') + }, + }) + const { project: made } = await store.startProject(rev) + await store.close() + const app = await start(where) + // The person found the project and saved a newer draft there before sending from the start screen again. + const newer = await save(app, made.id, 'A knob, in black') + expect((await send(app, 'new', rev)).id).toBe(made.id) + expect((await asked(app, made.id)).map((entry) => entry.text)).toEqual(['A knob']) + expect((await drafts(app))[made.id]).toEqual({ text: 'A knob, in black', attachments: [], rev: newer }) + await answered(app, made.id, 'answered by faux-a') + await app.quit() + }, 30_000) + test('makes the project, sends the draft, and leaves no draft behind', async () => { const where = scratch() const app = await start(where) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index a93efcdb..457510be 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -746,6 +746,54 @@ describe('StudioApp', () => { expect(prompt().value).toBe('') }) + it('saves text typed during a slow send at once when the app quits, before the send is done', async () => { + const sending = held() + mockSidecar({ 'chat.send': sending.call }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + type('A clip for my desk') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('A clip for my desk, and one for the shelf') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + // The quit holds for 1.5 s at most, so the newer text must reach the store before the send answers. + await waitFor(() => expect(last(saves(projectA.id))).toMatchObject({ text: 'A clip for my desk, and one for the shelf' })) + await sending.release() + }) + + it('never brings back a sent start picture from a saved state that was read before the send', async () => { + let reads = 0 + let answerLate: () => void = () => {} + const store = mockSidecar( + { + 'state.get': (_, real) => { + // macOS keeps the old chats from the app at first, so the window offers to import them again. + if (++reads === 1) return real().then((state) => ({ ...(state as object), importBlocked: '/Users/me/Documents/Materialize 3D' })) + if (reads !== 2) return real() + // The state the import retry reads now, answered only after the send went through. + const before = real() + return new Promise((resolve) => (answerLate = () => resolve(before))) + }, + 'legacy.import': () => Promise.resolve({ imported: 0, blocked: null }), + }, + [projectB], + { new: { text: '', attachments: ['/Users/me/pic.jpg'] } }, + ) + render() + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('pic.jpg')) + fireEvent.click(await screen.findByText('Try again')) + await waitFor(() => expect(reads).toBe(2)) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(store.sent).toHaveLength(1)) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('New part')) + await act(async () => answerLate()) + await pause(500) + expect(store.drafts.new).toBeUndefined() + fireEvent.click(screen.getByTestId('new-project')) + expect(screen.queryByTestId('attachments')).toBeNull() + }) + it('waits for a draft save already on its way before it says a quit may go on', async () => { const save = held() mockSidecar({ 'drafts.set': save.call }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 03fd6a2e..6dcf00fa 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -96,6 +96,11 @@ export default function StudioApp() { * screen changed, then names the revision. A draft keeps its pictures as the paths of their files. */ const screens = useRef>({}) + /** + * The newest revision sent from or to each screen. A saved state read before that send still holds the draft that + * went out, so its drafts at or below it are passed over. + */ + const sentRevs = useRef>({}) /** The open screen's save that waits for typing to pause. */ const pending = useRef<{ key: string; timer: number } | null>(null) /** The previews of pasted pictures, by path, freed when the window closes. */ @@ -178,6 +183,7 @@ export default function StudioApp() { const takeState = (state: AppState) => { // The saved state fills only the screens this window has not changed, since what it changed is newer. for (const [key, saved] of Object.entries(state.drafts)) { + if (saved.rev <= (sentRevs.current[key] ?? 0)) continue const local = screens.current[key] if (local === undefined) { const draft: Draft = { text: saved.text, attachments: saved.attachments } @@ -308,13 +314,16 @@ export default function StudioApp() { .catch((err: unknown) => setError(String(err))) } - // A quit or the window's close waits for each send and save on its way, saves every changed screen, then lets the - // quit go on. + // A quit or the window's close saves every changed screen, then waits for each send on its way and saves what it + // left, then lets the quit go on. useEffect(() => { const unlisten = onQuitFlush(async () => { clearPause() await Promise.all( Object.entries(screens.current).map(async ([key, screen]) => { + // What the composer holds now is saved first, even during a send, as Rust holds a quit only briefly. A send + // that it overtakes fails with its draft changed, and the newer draft keeps what went into it. + await flush(key, true) await screen.sending await flush(key) }), @@ -419,6 +428,7 @@ export default function StudioApp() { const sent = await flush(key, true) if (sent === undefined) return const { project } = await sidecar('chat.send', { key, rev: sent.rev }) + for (const at of [key, project.id]) sentRevs.current[at] = Math.max(sentRevs.current[at] ?? 0, sent.rev) setSendErrors(({ [project.id]: _, ...rest }) => rest) setProjects((prev) => (prev.some((p) => p.id === project.id) ? prev : [project, ...prev])) // What changed since the save that went out is the next message. Only the pictures that went out leave it. From 361b3c5151abca07af05f94be8f403eb71795541 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 18:09:21 -0600 Subject: [PATCH 55/59] fix(studio): the cleanup keeps a cut-off first send's pictures, and an import retry takes projects only From Sol's review of 964383e, with tests that failed first. - The cleanup at start also keeps the pictures a recorded start screen draft names, so a first send cut off after its project was made can still send them. - An import retry takes the project list and the blocked notice only, never drafts. Its saved state may be read before a send made meanwhile, and that brought a sent picture back. The revision filter that tried to stop it is gone, so an unsent picture from a late first state comes back as it should. - The quit saves during a send no more, which kept one draft under two screens and could bring a sent picture back. Text typed during a send longer than Rust's 1.5 s hold on a quit is a known limit, tracked in #206. The models' hash of app.ts is recorded again. TLC: every verdict as recorded. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 2 +- sidecar/src/app.ts | 6 ++-- sidecar/src/store.ts | 6 ++++ sidecar/test/switch.test.ts | 31 ++++++++++++++++++ src/studio/StudioApp.test.tsx | 32 ++++++++++++------- src/studio/StudioApp.tsx | 23 ++++++------- 6 files changed, 72 insertions(+), 28 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index b158be93..1047c150 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -7,4 +7,4 @@ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/ b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts 38c590f8dce792e7aa02b63188f88473c29d2b2001589055689c51d54aec10e4 sidecar/src/chat.ts -6d4eb78b893da94d0e56e02ea465b790981ae7d64fcce361d15ea42fea3c17d4 sidecar/src/app.ts +6f89db18e99fbe649f2b10a7476c4e548a401aac196d60053d16c5ebb46b9fec sidecar/src/app.ts diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index a521d62b..092abfd2 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -103,9 +103,11 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis await store.reviseOldDrafts() // A pasted picture is kept until it is sent, which copies it into its project, or its draft lets it go. A window that // outlived a crash may still hold one it has not saved in a draft yet, so only pictures older than a day that no - // draft names are removed. + // draft names are removed. A start screen send cut off after its project was made still sends the draft it moved, so + // that draft's pictures stay too. const pictures = join(dirname(data), 'pictures') - const named = new Set(Object.values((await store.state()).drafts).flatMap((draft) => draft.attachments)) + const kept = [...Object.values((await store.state()).drafts), ...(await store.startedDrafts())] + const named = new Set(kept.flatMap((draft) => draft.attachments)) const dayAgo = Date.now() - 86_400_000 for (const folder of existsSync(pictures) ? readdirSync(pictures) : []) { const path = join(pictures, folder) diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index a4e92606..c0a8dab0 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -283,6 +283,12 @@ export class Store implements LoginIndex { }, context) } + /** The drafts start screen sends moved to their projects, which a send of the same revision again still sends. */ + async startedDrafts(): Promise { + const index = await this.harness.snapshot(ProjectsDoc, this.root.id, context) + return Object.values(index?.madeFor ?? {}).map(({ draft }) => draft) + } + /** The draft saved under `key`, a project id or `START`. */ async draft(key: string): Promise { return (await this.harness.snapshot(DraftsDoc, this.root.id, context))?.drafts[key] diff --git a/sidecar/test/switch.test.ts b/sidecar/test/switch.test.ts index d810ee92..921b6fa8 100644 --- a/sidecar/test/switch.test.ts +++ b/sidecar/test/switch.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from 'bun:test' import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { createModels, InMemoryCredentialStore } from '@earendil-works/pi-ai' +import { createRegistry } from '@earendil-works/pi-durable' import type { ChatEntry, ChatView } from '../src/protocol' +import { Store } from '../src/store' import { spawnSidecar } from './sidecar' const LEGACY = join(import.meta.dir, 'fixtures', 'legacy') @@ -87,6 +90,34 @@ describe('a picture pasted into a draft', () => { await app.quit() }) + test("is kept while a first send cut off after its project was made still names it, though the project's draft changed", async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) + const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await first.next() + const picture = (await first.call('drafts.picture', { name: 'knob.png', data: '' })) as string + const { rev } = (await first.call('drafts.set', { key: 'new', text: 'A knob', attachments: [picture] })) as { rev: number } + await first.quit() + // The sidecar made the project and moved the draft, then stopped before the message. + const store = await Store.open({ + dataDir: join(dir, 'state'), + projectsDir: join(dir, 'projects'), + models: createModels({ credentials: new InMemoryCredentialStore() }), + registry: createRegistry(), + env: () => { + throw new Error('no conversation runs here') + }, + }) + const { project } = await store.startProject(rev) + // The person saved a newer draft in the project, without the picture, before sending from the start screen again. + await store.setDraft(project.id, { text: 'A knob, in black', attachments: [] }) + await store.close() + utimesSync(dirname(picture), new Date(Date.now() - 2 * 86_400_000), new Date(Date.now() - 2 * 86_400_000)) + const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects')) + await second.next() + expect(existsSync(picture)).toBe(true) + await second.quit() + }) + test('the cleanup at start passes over a link to nothing in the pictures folder', async () => { const dir = mkdtempSync(join(tmpdir(), 'm3d-picture-')) mkdirSync(join(dir, 'pictures')) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 457510be..34edefda 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -746,20 +746,28 @@ describe('StudioApp', () => { expect(prompt().value).toBe('') }) - it('saves text typed during a slow send at once when the app quits, before the send is done', async () => { - const sending = held() - mockSidecar({ 'chat.send': sending.call }, [projectA, projectB]) + it('brings back an unsent start picture from a first saved state that arrives after a text send', async () => { + let answerState: () => void = () => {} + const store = mockSidecar( + { + 'state.get': (_, real) => { + const before = real() + return new Promise((resolve) => (answerState = () => resolve(before))) + }, + }, + [projectB], + { new: { text: '', attachments: ['/Users/me/a.jpg'] } }, + ) render() - fireEvent.click(await screen.findByText('Project A')) - await screen.findByRole('banner') - type('A clip for my desk') + await screen.findByText('Engine 1.2.3') + type('A clip') fireEvent.click(screen.getByTestId('send')) - await waitFor(() => expect(sends()).toHaveLength(1)) - type('A clip for my desk, and one for the shelf') - await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) - // The quit holds for 1.5 s at most, so the newer text must reach the store before the send answers. - await waitFor(() => expect(last(saves(projectA.id))).toMatchObject({ text: 'A clip for my desk, and one for the shelf' })) - await sending.release() + await waitFor(() => expect(store.sent).toHaveLength(1)) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('A clip')) + await act(async () => answerState()) + fireEvent.click(screen.getByTestId('new-project')) + // a.jpg was saved on the start screen but never shown or sent, so it is still the start screen's. + await waitFor(() => expect(screen.getByTestId('attachments').textContent).toContain('a.jpg')) }) it('never brings back a sent start picture from a saved state that was read before the send', async () => { diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 6dcf00fa..287a3d95 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -96,11 +96,6 @@ export default function StudioApp() { * screen changed, then names the revision. A draft keeps its pictures as the paths of their files. */ const screens = useRef>({}) - /** - * The newest revision sent from or to each screen. A saved state read before that send still holds the draft that - * went out, so its drafts at or below it are passed over. - */ - const sentRevs = useRef>({}) /** The open screen's save that waits for typing to pause. */ const pending = useRef<{ key: string; timer: number } | null>(null) /** The previews of pasted pictures, by path, freed when the window closes. */ @@ -183,7 +178,6 @@ export default function StudioApp() { const takeState = (state: AppState) => { // The saved state fills only the screens this window has not changed, since what it changed is newer. for (const [key, saved] of Object.entries(state.drafts)) { - if (saved.rev <= (sentRevs.current[key] ?? 0)) continue const local = screens.current[key] if (local === undefined) { const draft: Draft = { text: saved.text, attachments: saved.attachments } @@ -195,6 +189,11 @@ export default function StudioApp() { autosave(START) } } + takeProjects(state) + } + + /** Takes the saved projects, and whether the old chats could be read. */ + const takeProjects = (state: AppState) => { // A project made while this was on its way is newer than the saved list, so it stays. setProjects((made) => [...made.filter((p) => !state.projects.some((saved) => saved.id === p.id)), ...state.projects]) setImportBlocked(state.importBlocked) @@ -207,10 +206,12 @@ export default function StudioApp() { .catch((err: unknown) => setError(String(err))) } + // An import retry brings old chats in as projects. Its saved state may be read before a send the person makes + // meanwhile, so it takes the projects only, never drafts, which this window already holds. const retryImport = () => { void sidecar('legacy.import', {}) .then(() => sidecar('state.get', {})) - .then(takeState) + .then(takeProjects) .catch((err: unknown) => setError(String(err))) } @@ -314,16 +315,13 @@ export default function StudioApp() { .catch((err: unknown) => setError(String(err))) } - // A quit or the window's close saves every changed screen, then waits for each send on its way and saves what it - // left, then lets the quit go on. + // A quit or the window's close waits for each send and save on its way, saves every changed screen, then lets the + // quit go on. Text typed during a send that outlasts Rust's hold on the quit is lost (#206). useEffect(() => { const unlisten = onQuitFlush(async () => { clearPause() await Promise.all( Object.entries(screens.current).map(async ([key, screen]) => { - // What the composer holds now is saved first, even during a send, as Rust holds a quit only briefly. A send - // that it overtakes fails with its draft changed, and the newer draft keeps what went into it. - await flush(key, true) await screen.sending await flush(key) }), @@ -428,7 +426,6 @@ export default function StudioApp() { const sent = await flush(key, true) if (sent === undefined) return const { project } = await sidecar('chat.send', { key, rev: sent.rev }) - for (const at of [key, project.id]) sentRevs.current[at] = Math.max(sentRevs.current[at] ?? 0, sent.rev) setSendErrors(({ [project.id]: _, ...rest }) => rest) setProjects((prev) => (prev.some((p) => p.id === project.id) ? prev : [project, ...prev])) // What changed since the save that went out is the next message. Only the pictures that went out leave it. From 05144bd220b578d1bc776d87a174017b6144f367 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 19:14:38 -0600 Subject: [PATCH 56/59] fix(studio): a send takes what was clicked, a quit loses no draft, and the first state is read again after a restart From Astra's review of 361b3c5 and Pullfrog's run on it, each with a test that failed first. - Send takes the composer as it was at the click, even when an earlier autosave was still on its way. Text typed while it waits stays the next message. - A quit: the window says at once that it is saving, and Rust then holds the quit until the window is done (a 60 s ceiling), not for 1.5 s. The window waits for pastes still being kept and for each send, then saves every screen, those a send just made included, with allSettled, so one failed save does not end the wait for the others. This closes #206. - If the first read of the saved state failed, it is read again once the engine is back. - A picture the person took out and chose again during a send stays in the next draft. - Two pictures whose names differ only in case get two copies. - The missing-uv test runs with an empty PATH. The models' hash of chat.ts is recorded again. TLC: every verdict as recorded. Closes #206. Co-Authored-By: Claude Opus 5.5 --- .../models/sources.sha256 | 2 +- sidecar/src/chat.ts | 5 +- sidecar/test/send.test.ts | 16 +++ sidecar/test/tools.test.ts | 3 +- src-tauri/build.rs | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/gen/schemas/desktop-schema.json | 12 ++ src-tauri/gen/schemas/linux-schema.json | 12 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/studio/quit.rs | 54 +++++++- src/studio/StudioApp.test.tsx | 127 ++++++++++++++++++ src/studio/StudioApp.tsx | 61 ++++++--- src/studio/api.ts | 10 +- 15 files changed, 276 insertions(+), 33 deletions(-) diff --git a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 index 1047c150..25ac8f59 100644 --- a/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 +++ b/.agents/skills/formal-verify-materialize-3d/models/sources.sha256 @@ -6,5 +6,5 @@ dc6f6f11328e5f422b59be488099f9203bcfe352a1fadcc3f523debca4a7bbd8 src-tauri/src/ dc1709c75444259b23f19d9210b730ba014074dcffc42453537d1f3a16cd5430 src-tauri/src/fabrication/pipeline.rs b6a70183e6dc2ae4abe76391342f4ce2c17536ea07f8976252c78eb6c46b17ad src-tauri/src/sidecar.rs 672150b06f7a26b26c4af16eaf9d878dab762c6bf37a406c87cf118f9e71f439 sidecar/src/busy.ts -38c590f8dce792e7aa02b63188f88473c29d2b2001589055689c51d54aec10e4 sidecar/src/chat.ts +d49a7e7b10a56f7fbe4e079b07a84c26e6728a1ab3fa38697c95a4e0b3c7558c sidecar/src/chat.ts 6f89db18e99fbe649f2b10a7476c4e548a401aac196d60053d16c5ebb46b9fec sidecar/src/app.ts diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 0817d861..ea259fba 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -104,9 +104,10 @@ function copyPictures(folder: string, rev: number, sources: string[]): string[] const file = clean.replace(/\./g, '') === '' ? 'picture.png' : clean const dot = file.lastIndexOf('.') const [stem, ext] = dot > 0 ? [file.slice(0, dot), file.slice(dot)] : [file, ''] + // A Mac volume ignores case in names, so two pictures whose names differ only in case get two names. let name = file - for (let n = 2; names.has(name); n++) name = `${stem}-${n}${ext}` - names.add(name) + for (let n = 2; names.has(name.toLowerCase()); n++) name = `${stem}-${n}${ext}` + names.add(name.toLowerCase()) const target = join(dir, name) if (lstatSync(target, { throwIfNoEntry: false }) === undefined) { // A picture that is gone can't be sent. Nor can a file that is not regular, such as a pipe, which would hold the copy. diff --git a/sidecar/test/send.test.ts b/sidecar/test/send.test.ts index b04cc33e..f8354f60 100644 --- a/sidecar/test/send.test.ts +++ b/sidecar/test/send.test.ts @@ -171,6 +171,22 @@ describe('a send from a project', () => { await app.quit() }) + test('gives two pictures whose names differ only in case two copies, as a Mac volume holds them in one name', async () => { + const where = scratch() + const app = await start(where) + const { project } = await app.sendDraft('new', 'A knob') + await answered(app, project, 'answered by faux-a') + for (const folder of ['a', 'b']) mkdirSync(join(where.dir, folder)) + writeFileSync(join(where.dir, 'a', 'Photo.jpg'), 'first') + writeFileSync(join(where.dir, 'b', 'photo.jpg'), 'second') + const rev = await save(app, project, 'Like these', [join(where.dir, 'a', 'Photo.jpg'), join(where.dir, 'b', 'photo.jpg')]) + await send(app, project, rev) + const images = (await asked(app, project))[1]?.images ?? [] + expect(images).toHaveLength(2) + expect(new Set(images.map((image) => image.toLowerCase())).size).toBe(2) + await app.quit() + }, 30_000) + test('copies its pictures once per revision, even when the first try was refused after the copy', async () => { const where = scratch() const app = await start(where) diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index a426fd0d..2992315e 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -145,7 +145,8 @@ describe('the check before a send', () => { } test('names a missing sign-in and a missing uv, each with its fix', async () => { - const needs = await check('/usr/bin:/bin', false) + // An empty folder as PATH, so no uv the machine has is found. + const needs = await check(mkdtempSync(join(tmpdir(), 'm3d-path-')), false) expect(needs.map((need) => need.kind)).toEqual(['login', 'uv']) expect(needs.every((need) => need.fix.length > 0)).toBe(true) }) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 6c851090..afc7746d 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -99,6 +99,7 @@ const APP_COMMANDS: &[&str] = &[ "studio_save_bytes", "studio_reveal", "studio_open_in_bambu", + "studio_flush_started", "studio_flushed", "studio_open_files_privacy", "sidecar", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index db1ab750..0306d86c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -92,6 +92,7 @@ "allow-studio-save-bytes", "allow-studio-reveal", "allow-studio-open-in-bambu", + "allow-studio-flush-started", "allow-studio-flushed", "allow-studio-open-files-privacy", "allow-sidecar" diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 4dcdb2cf..e14ebe03 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-flushed":{"identifier":"allow-studio-flushed","description":"Enables the studio_flushed command without any pre-configured scope.","commands":{"allow":["studio_flushed"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-files-privacy":{"identifier":"allow-studio-open-files-privacy","description":"Enables the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":["studio_open_files_privacy"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-flushed":{"identifier":"deny-studio-flushed","description":"Denies the studio_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flushed"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-files-privacy":{"identifier":"deny-studio-open-files-privacy","description":"Denies the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_files_privacy"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"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","allow-supports-multiple-windows"]},"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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"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-icon-with-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-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_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-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_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-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"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-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"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-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-flush-started":{"identifier":"allow-studio-flush-started","description":"Enables the studio_flush_started command without any pre-configured scope.","commands":{"allow":["studio_flush_started"],"deny":[]}},"allow-studio-flushed":{"identifier":"allow-studio-flushed","description":"Enables the studio_flushed command without any pre-configured scope.","commands":{"allow":["studio_flushed"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-files-privacy":{"identifier":"allow-studio-open-files-privacy","description":"Enables the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":["studio_open_files_privacy"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-flush-started":{"identifier":"deny-studio-flush-started","description":"Denies the studio_flush_started command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flush_started"]}},"deny-studio-flushed":{"identifier":"deny-studio-flushed","description":"Denies the studio_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flushed"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-files-privacy":{"identifier":"deny-studio-open-files-privacy","description":"Denies the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_files_privacy"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"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","allow-supports-multiple-windows"]},"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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"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-icon-with-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-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_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-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_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-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"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-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"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-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 2ab6e104..c385bf2d 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-studio-flushed","allow-studio-open-files-privacy","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-studio-flush-started","allow-studio-flushed","allow-studio-open-files-privacy","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index 9f2fc76a..fb682561 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -770,6 +770,12 @@ "const": "allow-studio-files", "markdownDescription": "Enables the studio_files command without any pre-configured scope." }, + { + "description": "Enables the studio_flush_started command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-flush-started", + "markdownDescription": "Enables the studio_flush_started command without any pre-configured scope." + }, { "description": "Enables the studio_flushed command without any pre-configured scope.", "type": "string", @@ -1274,6 +1280,12 @@ "const": "deny-studio-files", "markdownDescription": "Denies the studio_files command without any pre-configured scope." }, + { + "description": "Denies the studio_flush_started command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-flush-started", + "markdownDescription": "Denies the studio_flush_started command without any pre-configured scope." + }, { "description": "Denies the studio_flushed command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json index 9f2fc76a..fb682561 100644 --- a/src-tauri/gen/schemas/linux-schema.json +++ b/src-tauri/gen/schemas/linux-schema.json @@ -770,6 +770,12 @@ "const": "allow-studio-files", "markdownDescription": "Enables the studio_files command without any pre-configured scope." }, + { + "description": "Enables the studio_flush_started command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-flush-started", + "markdownDescription": "Enables the studio_flush_started command without any pre-configured scope." + }, { "description": "Enables the studio_flushed command without any pre-configured scope.", "type": "string", @@ -1274,6 +1280,12 @@ "const": "deny-studio-files", "markdownDescription": "Denies the studio_files command without any pre-configured scope." }, + { + "description": "Denies the studio_flush_started command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-flush-started", + "markdownDescription": "Denies the studio_flush_started command without any pre-configured scope." + }, { "description": "Denies the studio_flushed command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee6674da..8ef0e367 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -158,6 +158,7 @@ pub fn run() { studio::files::studio_save_bytes, studio::files::studio_reveal, studio::files::studio_open_in_bambu, + studio::quit::studio_flush_started, studio::quit::studio_flushed, studio::quit::studio_open_files_privacy, sidecar::sidecar, diff --git a/src-tauri/src/studio/quit.rs b/src-tauri/src/studio/quit.rs index 920ce373..084260bb 100644 --- a/src-tauri/src/studio/quit.rs +++ b/src-tauri/src/studio/quit.rs @@ -1,6 +1,8 @@ -//! A normal quit, or closing the window, waits briefly for the window to save what the person typed but has not -//! saved yet. The window saves its draft a moment after typing pauses, so text typed just before a quit would be lost -//! without this. A restart for an update cannot be held: Tauri ignores a hold on it, and the update waits for idle. +//! A normal quit, or closing the window, waits for the window to save what the person typed but has not saved yet. +//! The window saves its draft a moment after typing pauses, so text typed just before a quit would be lost without +//! this. The window says at once that it is saving, and then the quit waits for it to finish, as a send on its way +//! must end first. A window that does not answer, as one that is gone, holds the quit only briefly. A restart for an +//! update cannot be held: Tauri ignores a hold on it, and the update waits for idle. use std::sync::Mutex; use std::time::Duration; @@ -11,9 +13,14 @@ use tokio::sync::Notify; /// The event that asks the window to save its draft now. const FLUSH: &str = "studio://flush"; -/// How long a quit waits for the window. A window that does not answer, as one that is gone, never holds it longer. +/// How long a quit waits for the window to answer. A window that does not answer, as one that is gone, never holds it +/// longer. const FLUSH_WAIT: Duration = Duration::from_millis(1500); +/// How long a quit waits for a window that said it is saving. Long enough for a send on its way and every save, and a +/// ceiling only so a save that never ends cannot keep the app open forever. +const FLUSH_HOLD: Duration = Duration::from_secs(60); + /// What runs once the window has saved: the quit, or the window's close, each held for the save. type Held = Box; @@ -31,6 +38,8 @@ enum Stage { #[derive(Default)] pub struct QuitFlush { stage: Mutex, + /// The window started saving. + started: Notify, saved: Notify, } @@ -55,7 +64,18 @@ impl QuitFlush { let app = app.clone(); tauri::async_runtime::spawn(async move { if app.emit(FLUSH, ()).is_ok() { - let _ = tokio::time::timeout(FLUSH_WAIT, app.state::().saved.notified()).await; + let flush = app.state::(); + let answer = tokio::time::timeout(FLUSH_WAIT, async { + tokio::select! { + _ = flush.saved.notified() => true, + _ = flush.started.notified() => false, + } + }) + .await; + // The window is saving: wait until it is done. + if answer == Ok(false) { + let _ = tokio::time::timeout(FLUSH_HOLD, flush.saved.notified()).await; + } } let held = match std::mem::replace(&mut *app.state::().stage.lock().unwrap_or_else(|e| e.into_inner()), Stage::Saved) { Stage::Saving(held) => held, @@ -69,7 +89,13 @@ impl QuitFlush { } } -/// The window's answer: its draft is saved. +/// The window's first answer: it is saving, so the quit waits for it. +#[tauri::command] +pub fn studio_flush_started(flush: State<'_, QuitFlush>) { + flush.started.notify_one(); +} + +/// The window's last answer: everything is saved. #[tauri::command] pub fn studio_flushed(flush: State<'_, QuitFlush>) { flush.saved.notify_one(); @@ -115,6 +141,22 @@ mod tests { assert!(!QuitFlush::hold(app.handle(), move |_| done.send("again").unwrap()), "once saved, a quit goes through"); } + #[test] + fn a_window_that_started_saving_holds_the_quit_past_the_first_wait_until_it_is_done() { + let app = tauri::test::mock_app(); + app.manage(QuitFlush::default()); + let (done, finished) = std::sync::mpsc::channel(); + let started = std::time::Instant::now(); + assert!(QuitFlush::hold(app.handle(), move |_| done.send(()).unwrap())); + std::thread::sleep(Duration::from_millis(50)); + studio_flush_started(app.state::()); + // A save that takes longer than the first wait, as during a slow send, still holds the quit. + assert!(finished.recv_timeout(FLUSH_WAIT + Duration::from_millis(500)).is_err(), "the quit waited for the window"); + studio_flushed(app.state::()); + finished.recv_timeout(Duration::from_secs(1)).expect("the quit goes on once the window is done"); + assert!(started.elapsed() > FLUSH_WAIT, "held past the first wait, after {:?}", started.elapsed()); + } + #[test] fn the_windows_answer_ends_the_wait_at_once() { let app = tauri::test::mock_app(); diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 34edefda..009eed82 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -705,6 +705,31 @@ describe('StudioApp', () => { expect(store.sent).toHaveLength(1) }) + it('sends the message the person clicked Send on, even when they type more while an earlier autosave is still on its way', async () => { + let releaseFirst: () => void = () => {} + let calls = 0 + const store = mockSidecar( + { + 'drafts.set': (_, real) => (++calls === 1 ? new Promise((resolve) => (releaseFirst = () => resolve(real()))) : real()), + }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('Make it red') + await waitFor(() => expect(saves()).toHaveLength(1)) + fireEvent.click(screen.getByTestId('send')) + type('Make it blue') + await act(async () => releaseFirst()) + await waitFor(() => expect(store.sent).toHaveLength(1)) + expect(store.sent[0]).toMatchObject({ text: 'Make it red' }) + // The text typed after the click is the next message: it stays in the composer, and in the store. + await pause(600) + expect(prompt().value).toBe('Make it blue') + expect(store.drafts[projectB.id]).toMatchObject({ text: 'Make it blue' }) + }) + it('sends pictures restored from a late saved state under a new revision, never as a repeat of a sent one', async () => { let finishState: (state: unknown) => void = () => {} const store = mockSidecar({ 'state.get': () => new Promise((resolve) => (finishState = resolve)), 'chat.send': lostOnce() }) @@ -802,6 +827,108 @@ describe('StudioApp', () => { expect(screen.queryByTestId('attachments')).toBeNull() }) + it('waits for every screen\'s save before it says a quit may go on, even when another screen\'s save fails', async () => { + let releaseB: () => void = () => {} + mockSidecar( + { + 'drafts.set': (params, real) => { + if (params.key === projectA.id) return Promise.reject('The engine is starting.') + return new Promise((resolve) => (releaseB = () => resolve(real()))) + }, + }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('a hook') + fireEvent.click(screen.getByText('Project B')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) + type('a shelf') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await pause(50) + const flushed = () => invokeMock.mock.calls.some(([command]) => command === 'studio_flushed') + expect(flushed()).toBe(false) + await act(async () => releaseB()) + await waitFor(() => expect(flushed()).toBe(true)) + }) + + it('waits for a pasted picture still being kept before it says a quit may go on, and saves it with the draft', async () => { + let keep: () => void = () => {} + const store = mockSidecar({ 'drafts.picture': () => new Promise((resolve) => (keep = () => resolve('/state/pictures/1/shot.png'))) }, [projectA, projectB]) + render() + fireEvent.click(await screen.findByText('Project A')) + await screen.findByRole('banner') + fireEvent.paste(prompt(), { clipboardData: { files: [new File(['png'], 'shot.png', { type: 'image/png' })] } }) + await waitFor(() => expect(sidecarCalls('drafts.picture')).toHaveLength(1)) + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await pause(50) + const flushed = () => invokeMock.mock.calls.some(([command]) => command === 'studio_flushed') + expect(flushed()).toBe(false) + await act(async () => keep()) + await waitFor(() => expect(flushed()).toBe(true)) + expect(store.drafts[projectA.id]).toMatchObject({ attachments: ['/state/pictures/1/shot.png'] }) + }) + + it('saves the draft a first send made for its project during a quit before it says the quit may go on', async () => { + const sending = held() + const store = mockSidecar({ 'chat.send': sending.call }) + render() + await screen.findByText('Project B') + type('A knob') + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('A knob, and a dial') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await sending.release() + const flushed = () => invokeMock.mock.calls.some(([command]) => command === 'studio_flushed') + await waitFor(() => expect(flushed()).toBe(true)) + const project = store.projects.find((p) => p.title === 'A knob') + expect(project).toBeDefined() + expect(store.drafts[project?.id ?? '']).toMatchObject({ text: 'A knob, and a dial' }) + }) + + it('tells Rust at once that it is saving when a quit asks, so Rust holds the quit until the window is done', async () => { + const save = held() + mockSidecar({ 'drafts.set': save.call }) + render() + fireEvent.click(await screen.findByText('Project B')) + await screen.findByRole('banner') + type('half a thought') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + expect(said('studio_flush_started')).toBe(true) + expect(said('studio_flushed')).toBe(false) + await save.release() + await waitFor(() => expect(said('studio_flushed')).toBe(true)) + }) + + it('reads the saved state again when the engine comes back, if the first read failed', async () => { + let reads = 0 + mockSidecar({ 'state.get': (_, real) => (++reads === 1 ? Promise.reject('The engine stopped.') : real()) }, [projectA, projectB]) + render() + await waitFor(() => expect(reads).toBe(1)) + expect(screen.queryByText('Project A')).toBeNull() + await emit({ type: 'ready', version: '1.2.3' }) + expect(await screen.findByText('Project A')).toBeTruthy() + }) + + it('keeps a picture the person took out and put back while its send was on its way', async () => { + const sending = held() + mockSidecar({ 'chat.send': sending.call }, [projectA, projectB], { [projectA.id]: { text: 'A clip like this', attachments: ['/Users/me/pic.jpg'] } }) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(prompt().value).toBe('A clip like this')) + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + fireEvent.click(screen.getByLabelText('Remove pic.jpg')) + await act(async () => listeners.drop.forEach((listener) => listener({ payload: { type: 'drop', paths: ['/Users/me/pic.jpg'] } }))) + await sending.release() + await pause(100) + // The person chose it again, so it is part of the next message. + expect(screen.getByTestId('attachments').textContent).toContain('pic.jpg') + }) + it('waits for a draft save already on its way before it says a quit may go on', async () => { const save = held() mockSidecar({ 'drafts.set': save.call }) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 287a3d95..386e451a 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -36,6 +36,11 @@ type Screen = { saving?: Promise /** The send on its way. Nothing else saves the screen meanwhile, as that would replace the draft it sends. */ sending?: Promise + /** + * The pictures that send carries, but for any the person took out since. A sent picture leaves the next draft when + * the send goes through, unless the person took it out and chose it again. + */ + sendingPictures?: Set } /** Whether the screen changed since its last completed save. Each change makes a new draft object. */ @@ -98,6 +103,8 @@ export default function StudioApp() { const screens = useRef>({}) /** The open screen's save that waits for typing to pause. */ const pending = useRef<{ key: string; timer: number } | null>(null) + /** Pastes whose pictures are still being kept, so a quit waits for them. */ + const pastes = useRef(new Set>()) /** The previews of pasted pictures, by path, freed when the window closes. */ const previews = useRef(new Map()) /** Whether the person changed the start screen's pictures, so its saved ones no longer fill it. */ @@ -134,15 +141,17 @@ export default function StudioApp() { /** * Saves a screen until the store holds what it shows, one save at a time: a save wanted while one runs waits for it, - * then saves the newest draft. While the screen's send is on its way, only that send saves it. Answers the last + * then saves the newest draft. While the screen's send is on its way, only that send saves it, and it saves `going`, + * the draft taken at the click, so text typed while an earlier save finished stays the next message. Answers the last * completed save. */ - const flush = async (key: string, forSend = false) => { + const flush = async (key: string, going?: Draft) => { const screen = screens.current[key] if (screen === undefined) return undefined while (screen.saving !== undefined) await screen.saving - if ((screen.sending === undefined || forSend) && changed(screen)) { - const draft = screen.draft + const wanted = going === undefined ? (screen.sending === undefined && changed(screen) ? screen.draft : undefined) : going + if (wanted !== undefined && wanted !== screen.saved?.draft) { + const draft = wanted const save = sidecar('drafts.set', { key, ...draft }).then(({ rev }) => { screen.saved = { draft, rev } }) @@ -216,9 +225,17 @@ export default function StudioApp() { } useEffect(() => { - sidecar('state.get', {}) - .then(takeState) - .catch((err: unknown) => setError(String(err))) + // The first read of the saved state. If the engine stopped before it answered, it is read again once the engine is + // back, so the projects and drafts still come. + let stateRead = false + const readState = () => + sidecar('state.get', {}) + .then((state) => { + stateRead = true + takeState(state) + }) + .catch((err: unknown) => setError(String(err))) + void readState() checkReady() // The listener is in place before the ping, so no engine change is missed, and a ping that answers late never // hides one. @@ -230,6 +247,7 @@ export default function StudioApp() { setEngine(`Engine ${event.version}`) // A check that failed while the engine started again is asked again, so Send does not stay off. checkReady() + if (!stateRead) void readState() // An engine that started again holds what a crash cut off from the window, such as a turn's last entry, so // the open chat is read again. The changes that arrive meanwhile are kept, as when it first opened. const project = currentId.current @@ -286,6 +304,8 @@ export default function StudioApp() { /** Changes the open screen's pictures, and saves them with its draft. */ const setPictures = (paths: string[]) => { if (openKey() === START) startPicturesTouched.current = true + const sending = screens.current[openKey()]?.sendingPictures + for (const path of draftOf(openKey()).attachments) if (!paths.includes(path)) sending?.delete(path) edit({ ...draftOf(openKey()), attachments: paths }) } @@ -295,7 +315,7 @@ export default function StudioApp() { */ const pastePictures = (pasted: PastedPicture[]) => { const key = openKey() - void Promise.all( + const landing = Promise.all( pasted.map(async ({ name, file }) => ({ path: await sidecar('drafts.picture', { name, data: toBase64(new Uint8Array(await file.arrayBuffer())) }), file, @@ -313,19 +333,19 @@ export default function StudioApp() { autosave(key) }) .catch((err: unknown) => setError(String(err))) + pastes.current.add(landing) + void landing.finally(() => pastes.current.delete(landing)) } - // A quit or the window's close waits for each send and save on its way, saves every changed screen, then lets the - // quit go on. Text typed during a send that outlasts Rust's hold on the quit is lost (#206). + // A quit or the window's close waits for pasted pictures still being kept and each send on its way, then saves every + // screen, those a send just made included, and only then lets the quit go on. Rust holds the quit meanwhile. One save + // that fails does not end the wait for the others. useEffect(() => { const unlisten = onQuitFlush(async () => { clearPause() - await Promise.all( - Object.entries(screens.current).map(async ([key, screen]) => { - await screen.sending - await flush(key) - }), - ) + await Promise.allSettled([...pastes.current]) + await Promise.allSettled(Object.values(screens.current).map((screen) => screen.sending)) + await Promise.allSettled(Object.keys(screens.current).map((key) => flush(key))) }) return () => { void unlisten.then((stop) => stop()) @@ -423,13 +443,15 @@ export default function StudioApp() { let done = () => {} screen.sending = new Promise((resolve) => (done = resolve)) try { - const sent = await flush(key, true) + screen.sendingPictures = new Set(screen.draft.attachments) + const sent = await flush(key, screen.draft) if (sent === undefined) return const { project } = await sidecar('chat.send', { key, rev: sent.rev }) setSendErrors(({ [project.id]: _, ...rest }) => rest) setProjects((prev) => (prev.some((p) => p.id === project.id) ? prev : [project, ...prev])) - // What changed since the save that went out is the next message. Only the pictures that went out leave it. - const outgoing = new Set(sent.draft.attachments) + // What changed since the save that went out is the next message. The pictures that went out leave it, but for one + // the person took out and chose again during the send. + const outgoing = screen.sendingPictures ?? new Set() const next: Draft = { ...screen.draft, attachments: screen.draft.attachments.filter((path) => !outgoing.has(path)) } const unchanged = screen.draft === sent.draft if (key === START && openKey() === START) { @@ -450,6 +472,7 @@ export default function StudioApp() { else if (openKey() === START) setError(message) } finally { delete screen.sending + delete screen.sendingPictures done() sendingNow.current = false setSending(false) diff --git a/src/studio/api.ts b/src/studio/api.ts index 3ae92c76..877ca4d7 100644 --- a/src/studio/api.ts +++ b/src/studio/api.ts @@ -9,9 +9,15 @@ export const sidecar = (method: M, params: Methods[M]['params' export const onSidecarEvent = (handler: (event: SidecarEvent) => void) => listen('sidecar://event', ({ payload }) => handler(payload)) -/** Rust asks for this as the app quits or the window closes: save the draft now, then say it is done. */ +/** + * Rust asks for this as the app quits or the window closes. The window says at once that it is saving, so Rust holds + * the quit for as long as that takes, then says it is done. + */ export const onQuitFlush = (save: () => Promise) => - listen('studio://flush', () => void save().finally(() => invoke('studio_flushed'))) + listen('studio://flush', () => { + void invoke('studio_flush_started') + void save().finally(() => invoke('studio_flushed')) + }) /** Opens the macOS setting that lets the app read Documents. */ export const openFilesPrivacy = () => invoke('studio_open_files_privacy') From 4798ac36d1bc740c66b5725cc540419dd1863186 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 20:02:39 -0600 Subject: [PATCH 57/59] fix(studio): a quit never goes on with a draft unsaved From Astra's review of 05144bd, with tests that failed first. - A quit's save the engine refused, as while it restarts, is tried again once the engine is back, or after 3 s, up to four tries. - A draft that still cannot be saved calls the quit off. The window answers studio_flush_failed, never studio_flushed, and names the problem in one line. Rust then drops the held quit or close, so the app stays open with the draft, and the next quit asks the window again. - The quit test now checks that both drafts survive a refused save. The send path is unchanged. Co-Authored-By: Claude Opus 5.5 --- src-tauri/build.rs | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/gen/schemas/desktop-schema.json | 12 ++++ src-tauri/gen/schemas/linux-schema.json | 12 ++++ src-tauri/src/lib.rs | 1 + src-tauri/src/studio/quit.rs | 76 ++++++++++++++++++----- src/studio/StudioApp.test.tsx | 38 +++++++++--- src/studio/StudioApp.tsx | 24 ++++++- src/studio/api.ts | 8 ++- 11 files changed, 146 insertions(+), 31 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index afc7746d..a129c7b6 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -100,6 +100,7 @@ const APP_COMMANDS: &[&str] = &[ "studio_reveal", "studio_open_in_bambu", "studio_flush_started", + "studio_flush_failed", "studio_flushed", "studio_open_files_privacy", "sidecar", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0306d86c..3ddd8371 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -93,6 +93,7 @@ "allow-studio-reveal", "allow-studio-open-in-bambu", "allow-studio-flush-started", + "allow-studio-flush-failed", "allow-studio-flushed", "allow-studio-open-files-privacy", "allow-sidecar" diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index e14ebe03..c755ecb7 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-flush-started":{"identifier":"allow-studio-flush-started","description":"Enables the studio_flush_started command without any pre-configured scope.","commands":{"allow":["studio_flush_started"],"deny":[]}},"allow-studio-flushed":{"identifier":"allow-studio-flushed","description":"Enables the studio_flushed command without any pre-configured scope.","commands":{"allow":["studio_flushed"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-files-privacy":{"identifier":"allow-studio-open-files-privacy","description":"Enables the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":["studio_open_files_privacy"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-flush-started":{"identifier":"deny-studio-flush-started","description":"Denies the studio_flush_started command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flush_started"]}},"deny-studio-flushed":{"identifier":"deny-studio-flushed","description":"Denies the studio_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flushed"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-files-privacy":{"identifier":"deny-studio-open-files-privacy","description":"Denies the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_files_privacy"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"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","allow-supports-multiple-windows"]},"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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"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-icon-with-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-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_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-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_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-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"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-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"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-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-printer-config":{"identifier":"allow-add-printer-config","description":"Enables the add_printer_config command without any pre-configured scope.","commands":{"allow":["add_printer_config"],"deny":[]}},"allow-add-to-queue":{"identifier":"allow-add-to-queue","description":"Enables the add_to_queue command without any pre-configured scope.","commands":{"allow":["add_to_queue"],"deny":[]}},"allow-agent-cancel":{"identifier":"allow-agent-cancel","description":"Enables the agent_cancel command without any pre-configured scope.","commands":{"allow":["agent_cancel"],"deny":[]}},"allow-agent-clear-api-key":{"identifier":"allow-agent-clear-api-key","description":"Enables the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":["agent_clear_api_key"],"deny":[]}},"allow-agent-history":{"identifier":"allow-agent-history","description":"Enables the agent_history command without any pre-configured scope.","commands":{"allow":["agent_history"],"deny":[]}},"allow-agent-new-conversation":{"identifier":"allow-agent-new-conversation","description":"Enables the agent_new_conversation command without any pre-configured scope.","commands":{"allow":["agent_new_conversation"],"deny":[]}},"allow-agent-send":{"identifier":"allow-agent-send","description":"Enables the agent_send command without any pre-configured scope.","commands":{"allow":["agent_send"],"deny":[]}},"allow-agent-set-api-key":{"identifier":"allow-agent-set-api-key","description":"Enables the agent_set_api_key command without any pre-configured scope.","commands":{"allow":["agent_set_api_key"],"deny":[]}},"allow-agent-set-base-url":{"identifier":"allow-agent-set-base-url","description":"Enables the agent_set_base_url command without any pre-configured scope.","commands":{"allow":["agent_set_base_url"],"deny":[]}},"allow-agent-set-model":{"identifier":"allow-agent-set-model","description":"Enables the agent_set_model command without any pre-configured scope.","commands":{"allow":["agent_set_model"],"deny":[]}},"allow-agent-status":{"identifier":"allow-agent-status","description":"Enables the agent_status command without any pre-configured scope.","commands":{"allow":["agent_status"],"deny":[]}},"allow-bambu-studio-status":{"identifier":"allow-bambu-studio-status","description":"Enables the bambu_studio_status command without any pre-configured scope.","commands":{"allow":["bambu_studio_status"],"deny":[]}},"allow-choose-bambu-studio":{"identifier":"allow-choose-bambu-studio","description":"Enables the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":["choose_bambu_studio"],"deny":[]}},"allow-clear-bambu-studio":{"identifier":"allow-clear-bambu-studio","description":"Enables the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":["clear_bambu_studio"],"deny":[]}},"allow-connect-printer":{"identifier":"allow-connect-printer","description":"Enables the connect_printer command without any pre-configured scope.","commands":{"allow":["connect_printer"],"deny":[]}},"allow-connect-printer-by-config":{"identifier":"allow-connect-printer-by-config","description":"Enables the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":["connect_printer_by_config"],"deny":[]}},"allow-delete-credential":{"identifier":"allow-delete-credential","description":"Enables the delete_credential command without any pre-configured scope.","commands":{"allow":["delete_credential"],"deny":[]}},"allow-delete-library-model":{"identifier":"allow-delete-library-model","description":"Enables the delete_library_model command without any pre-configured scope.","commands":{"allow":["delete_library_model"],"deny":[]}},"allow-delete-print-history-item":{"identifier":"allow-delete-print-history-item","description":"Enables the delete_print_history_item command without any pre-configured scope.","commands":{"allow":["delete_print_history_item"],"deny":[]}},"allow-delete-printer-config":{"identifier":"allow-delete-printer-config","description":"Enables the delete_printer_config command without any pre-configured scope.","commands":{"allow":["delete_printer_config"],"deny":[]}},"allow-design-approve":{"identifier":"allow-design-approve","description":"Enables the design_approve command without any pre-configured scope.","commands":{"allow":["design_approve"],"deny":[]}},"allow-design-build":{"identifier":"allow-design-build","description":"Enables the design_build command without any pre-configured scope.","commands":{"allow":["design_build"],"deny":[]}},"allow-design-cancel":{"identifier":"allow-design-cancel","description":"Enables the design_cancel command without any pre-configured scope.","commands":{"allow":["design_cancel"],"deny":[]}},"allow-design-export":{"identifier":"allow-design-export","description":"Enables the design_export command without any pre-configured scope.","commands":{"allow":["design_export"],"deny":[]}},"allow-design-get":{"identifier":"allow-design-get","description":"Enables the design_get command without any pre-configured scope.","commands":{"allow":["design_get"],"deny":[]}},"allow-design-lineage":{"identifier":"allow-design-lineage","description":"Enables the design_lineage command without any pre-configured scope.","commands":{"allow":["design_lineage"],"deny":[]}},"allow-design-list":{"identifier":"allow-design-list","description":"Enables the design_list command without any pre-configured scope.","commands":{"allow":["design_list"],"deny":[]}},"allow-design-preview":{"identifier":"allow-design-preview","description":"Enables the design_preview command without any pre-configured scope.","commands":{"allow":["design_preview"],"deny":[]}},"allow-design-record-print":{"identifier":"allow-design-record-print","description":"Enables the design_record_print command without any pre-configured scope.","commands":{"allow":["design_record_print"],"deny":[]}},"allow-disconnect-printer":{"identifier":"allow-disconnect-printer","description":"Enables the disconnect_printer command without any pre-configured scope.","commands":{"allow":["disconnect_printer"],"deny":[]}},"allow-download-model":{"identifier":"allow-download-model","description":"Enables the download_model command without any pre-configured scope.","commands":{"allow":["download_model"],"deny":[]}},"allow-get-app-state":{"identifier":"allow-get-app-state","description":"Enables the get_app_state command without any pre-configured scope.","commands":{"allow":["get_app_state"],"deny":[]}},"allow-get-credential":{"identifier":"allow-get-credential","description":"Enables the get_credential command without any pre-configured scope.","commands":{"allow":["get_credential"],"deny":[]}},"allow-get-designs-dir":{"identifier":"allow-get-designs-dir","description":"Enables the get_designs_dir command without any pre-configured scope.","commands":{"allow":["get_designs_dir"],"deny":[]}},"allow-get-library-models":{"identifier":"allow-get-library-models","description":"Enables the get_library_models command without any pre-configured scope.","commands":{"allow":["get_library_models"],"deny":[]}},"allow-get-print-history":{"identifier":"allow-get-print-history","description":"Enables the get_print_history command without any pre-configured scope.","commands":{"allow":["get_print_history"],"deny":[]}},"allow-get-printer-configs":{"identifier":"allow-get-printer-configs","description":"Enables the get_printer_configs command without any pre-configured scope.","commands":{"allow":["get_printer_configs"],"deny":[]}},"allow-get-printer-status":{"identifier":"allow-get-printer-status","description":"Enables the get_printer_status command without any pre-configured scope.","commands":{"allow":["get_printer_status"],"deny":[]}},"allow-get-queue":{"identifier":"allow-get-queue","description":"Enables the get_queue command without any pre-configured scope.","commands":{"allow":["get_queue"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-has-credential":{"identifier":"allow-has-credential","description":"Enables the has_credential command without any pre-configured scope.","commands":{"allow":["has_credential"],"deny":[]}},"allow-list-profiles":{"identifier":"allow-list-profiles","description":"Enables the list_profiles command without any pre-configured scope.","commands":{"allow":["list_profiles"],"deny":[]}},"allow-makerworld-back":{"identifier":"allow-makerworld-back","description":"Enables the makerworld_back command without any pre-configured scope.","commands":{"allow":["makerworld_back"],"deny":[]}},"allow-makerworld-forward":{"identifier":"allow-makerworld-forward","description":"Enables the makerworld_forward command without any pre-configured scope.","commands":{"allow":["makerworld_forward"],"deny":[]}},"allow-makerworld-reload":{"identifier":"allow-makerworld-reload","description":"Enables the makerworld_reload command without any pre-configured scope.","commands":{"allow":["makerworld_reload"],"deny":[]}},"allow-mcp-rotate-token":{"identifier":"allow-mcp-rotate-token","description":"Enables the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":["mcp_rotate_token"],"deny":[]}},"allow-mcp-set-enabled":{"identifier":"allow-mcp-set-enabled","description":"Enables the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":["mcp_set_enabled"],"deny":[]}},"allow-mcp-status":{"identifier":"allow-mcp-status","description":"Enables the mcp_status command without any pre-configured scope.","commands":{"allow":["mcp_status"],"deny":[]}},"allow-mcp-token":{"identifier":"allow-mcp-token","description":"Enables the mcp_token command without any pre-configured scope.","commands":{"allow":["mcp_token"],"deny":[]}},"allow-navigate-makerworld":{"identifier":"allow-navigate-makerworld","description":"Enables the navigate_makerworld command without any pre-configured scope.","commands":{"allow":["navigate_makerworld"],"deny":[]}},"allow-open-library-model":{"identifier":"allow-open-library-model","description":"Enables the open_library_model command without any pre-configured scope.","commands":{"allow":["open_library_model"],"deny":[]}},"allow-openscad-check-installed":{"identifier":"allow-openscad-check-installed","description":"Enables the openscad_check_installed command without any pre-configured scope.","commands":{"allow":["openscad_check_installed"],"deny":[]}},"allow-openscad-extract-params":{"identifier":"allow-openscad-extract-params","description":"Enables the openscad_extract_params command without any pre-configured scope.","commands":{"allow":["openscad_extract_params"],"deny":[]}},"allow-openscad-render":{"identifier":"allow-openscad-render","description":"Enables the openscad_render command without any pre-configured scope.","commands":{"allow":["openscad_render"],"deny":[]}},"allow-pause-print":{"identifier":"allow-pause-print","description":"Enables the pause_print command without any pre-configured scope.","commands":{"allow":["pause_print"],"deny":[]}},"allow-probe-camera":{"identifier":"allow-probe-camera","description":"Enables the probe_camera command without any pre-configured scope.","commands":{"allow":["probe_camera"],"deny":[]}},"allow-read-model-file":{"identifier":"allow-read-model-file","description":"Enables the read_model_file command without any pre-configured scope.","commands":{"allow":["read_model_file"],"deny":[]}},"allow-read-text-file":{"identifier":"allow-read-text-file","description":"Enables the read_text_file command without any pre-configured scope.","commands":{"allow":["read_text_file"],"deny":[]}},"allow-remove-from-queue":{"identifier":"allow-remove-from-queue","description":"Enables the remove_from_queue command without any pre-configured scope.","commands":{"allow":["remove_from_queue"],"deny":[]}},"allow-report-makerworld-import-attempt":{"identifier":"allow-report-makerworld-import-attempt","description":"Enables the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":["report_makerworld_import_attempt"],"deny":[]}},"allow-report-makerworld-page":{"identifier":"allow-report-makerworld-page","description":"Enables the report_makerworld_page command without any pre-configured scope.","commands":{"allow":["report_makerworld_page"],"deny":[]}},"allow-resume-print":{"identifier":"allow-resume-print","description":"Enables the resume_print command without any pre-configured scope.","commands":{"allow":["resume_print"],"deny":[]}},"allow-search-library-models":{"identifier":"allow-search-library-models","description":"Enables the search_library_models command without any pre-configured scope.","commands":{"allow":["search_library_models"],"deny":[]}},"allow-search-makerworld":{"identifier":"allow-search-makerworld","description":"Enables the search_makerworld command without any pre-configured scope.","commands":{"allow":["search_makerworld"],"deny":[]}},"allow-set-active-view":{"identifier":"allow-set-active-view","description":"Enables the set_active_view command without any pre-configured scope.","commands":{"allow":["set_active_view"],"deny":[]}},"allow-set-default-printer":{"identifier":"allow-set-default-printer","description":"Enables the set_default_printer command without any pre-configured scope.","commands":{"allow":["set_default_printer"],"deny":[]}},"allow-sidecar":{"identifier":"allow-sidecar","description":"Enables the sidecar command without any pre-configured scope.","commands":{"allow":["sidecar"],"deny":[]}},"allow-slice-model":{"identifier":"allow-slice-model","description":"Enables the slice_model command without any pre-configured scope.","commands":{"allow":["slice_model"],"deny":[]}},"allow-start-print":{"identifier":"allow-start-print","description":"Enables the start_print command without any pre-configured scope.","commands":{"allow":["start_print"],"deny":[]}},"allow-store-credential":{"identifier":"allow-store-credential","description":"Enables the store_credential command without any pre-configured scope.","commands":{"allow":["store_credential"],"deny":[]}},"allow-studio-files":{"identifier":"allow-studio-files","description":"Enables the studio_files command without any pre-configured scope.","commands":{"allow":["studio_files"],"deny":[]}},"allow-studio-flush-failed":{"identifier":"allow-studio-flush-failed","description":"Enables the studio_flush_failed command without any pre-configured scope.","commands":{"allow":["studio_flush_failed"],"deny":[]}},"allow-studio-flush-started":{"identifier":"allow-studio-flush-started","description":"Enables the studio_flush_started command without any pre-configured scope.","commands":{"allow":["studio_flush_started"],"deny":[]}},"allow-studio-flushed":{"identifier":"allow-studio-flushed","description":"Enables the studio_flushed command without any pre-configured scope.","commands":{"allow":["studio_flushed"],"deny":[]}},"allow-studio-import-file":{"identifier":"allow-studio-import-file","description":"Enables the studio_import_file command without any pre-configured scope.","commands":{"allow":["studio_import_file"],"deny":[]}},"allow-studio-open-files-privacy":{"identifier":"allow-studio-open-files-privacy","description":"Enables the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":["studio_open_files_privacy"],"deny":[]}},"allow-studio-open-in-bambu":{"identifier":"allow-studio-open-in-bambu","description":"Enables the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":["studio_open_in_bambu"],"deny":[]}},"allow-studio-reveal":{"identifier":"allow-studio-reveal","description":"Enables the studio_reveal command without any pre-configured scope.","commands":{"allow":["studio_reveal"],"deny":[]}},"allow-studio-save-bytes":{"identifier":"allow-studio-save-bytes","description":"Enables the studio_save_bytes command without any pre-configured scope.","commands":{"allow":["studio_save_bytes"],"deny":[]}},"allow-switch-printer":{"identifier":"allow-switch-printer","description":"Enables the switch_printer command without any pre-configured scope.","commands":{"allow":["switch_printer"],"deny":[]}},"allow-sync-makerworld-webview":{"identifier":"allow-sync-makerworld-webview","description":"Enables the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":["sync_makerworld_webview"],"deny":[]}},"allow-update-check":{"identifier":"allow-update-check","description":"Enables the update_check command without any pre-configured scope.","commands":{"allow":["update_check"],"deny":[]}},"allow-update-install":{"identifier":"allow-update-install","description":"Enables the update_install command without any pre-configured scope.","commands":{"allow":["update_install"],"deny":[]}},"allow-update-printer-config":{"identifier":"allow-update-printer-config","description":"Enables the update_printer_config command without any pre-configured scope.","commands":{"allow":["update_printer_config"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-write-text-file":{"identifier":"allow-write-text-file","description":"Enables the write_text_file command without any pre-configured scope.","commands":{"allow":["write_text_file"],"deny":[]}},"deny-add-printer-config":{"identifier":"deny-add-printer-config","description":"Denies the add_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["add_printer_config"]}},"deny-add-to-queue":{"identifier":"deny-add-to-queue","description":"Denies the add_to_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["add_to_queue"]}},"deny-agent-cancel":{"identifier":"deny-agent-cancel","description":"Denies the agent_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_cancel"]}},"deny-agent-clear-api-key":{"identifier":"deny-agent-clear-api-key","description":"Denies the agent_clear_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_clear_api_key"]}},"deny-agent-history":{"identifier":"deny-agent-history","description":"Denies the agent_history command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_history"]}},"deny-agent-new-conversation":{"identifier":"deny-agent-new-conversation","description":"Denies the agent_new_conversation command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_new_conversation"]}},"deny-agent-send":{"identifier":"deny-agent-send","description":"Denies the agent_send command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_send"]}},"deny-agent-set-api-key":{"identifier":"deny-agent-set-api-key","description":"Denies the agent_set_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_api_key"]}},"deny-agent-set-base-url":{"identifier":"deny-agent-set-base-url","description":"Denies the agent_set_base_url command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_base_url"]}},"deny-agent-set-model":{"identifier":"deny-agent-set-model","description":"Denies the agent_set_model command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_set_model"]}},"deny-agent-status":{"identifier":"deny-agent-status","description":"Denies the agent_status command without any pre-configured scope.","commands":{"allow":[],"deny":["agent_status"]}},"deny-bambu-studio-status":{"identifier":"deny-bambu-studio-status","description":"Denies the bambu_studio_status command without any pre-configured scope.","commands":{"allow":[],"deny":["bambu_studio_status"]}},"deny-choose-bambu-studio":{"identifier":"deny-choose-bambu-studio","description":"Denies the choose_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["choose_bambu_studio"]}},"deny-clear-bambu-studio":{"identifier":"deny-clear-bambu-studio","description":"Denies the clear_bambu_studio command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_bambu_studio"]}},"deny-connect-printer":{"identifier":"deny-connect-printer","description":"Denies the connect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer"]}},"deny-connect-printer-by-config":{"identifier":"deny-connect-printer-by-config","description":"Denies the connect_printer_by_config command without any pre-configured scope.","commands":{"allow":[],"deny":["connect_printer_by_config"]}},"deny-delete-credential":{"identifier":"deny-delete-credential","description":"Denies the delete_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_credential"]}},"deny-delete-library-model":{"identifier":"deny-delete-library-model","description":"Denies the delete_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_library_model"]}},"deny-delete-print-history-item":{"identifier":"deny-delete-print-history-item","description":"Denies the delete_print_history_item command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_print_history_item"]}},"deny-delete-printer-config":{"identifier":"deny-delete-printer-config","description":"Denies the delete_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_printer_config"]}},"deny-design-approve":{"identifier":"deny-design-approve","description":"Denies the design_approve command without any pre-configured scope.","commands":{"allow":[],"deny":["design_approve"]}},"deny-design-build":{"identifier":"deny-design-build","description":"Denies the design_build command without any pre-configured scope.","commands":{"allow":[],"deny":["design_build"]}},"deny-design-cancel":{"identifier":"deny-design-cancel","description":"Denies the design_cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["design_cancel"]}},"deny-design-export":{"identifier":"deny-design-export","description":"Denies the design_export command without any pre-configured scope.","commands":{"allow":[],"deny":["design_export"]}},"deny-design-get":{"identifier":"deny-design-get","description":"Denies the design_get command without any pre-configured scope.","commands":{"allow":[],"deny":["design_get"]}},"deny-design-lineage":{"identifier":"deny-design-lineage","description":"Denies the design_lineage command without any pre-configured scope.","commands":{"allow":[],"deny":["design_lineage"]}},"deny-design-list":{"identifier":"deny-design-list","description":"Denies the design_list command without any pre-configured scope.","commands":{"allow":[],"deny":["design_list"]}},"deny-design-preview":{"identifier":"deny-design-preview","description":"Denies the design_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["design_preview"]}},"deny-design-record-print":{"identifier":"deny-design-record-print","description":"Denies the design_record_print command without any pre-configured scope.","commands":{"allow":[],"deny":["design_record_print"]}},"deny-disconnect-printer":{"identifier":"deny-disconnect-printer","description":"Denies the disconnect_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["disconnect_printer"]}},"deny-download-model":{"identifier":"deny-download-model","description":"Denies the download_model command without any pre-configured scope.","commands":{"allow":[],"deny":["download_model"]}},"deny-get-app-state":{"identifier":"deny-get-app-state","description":"Denies the get_app_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_state"]}},"deny-get-credential":{"identifier":"deny-get-credential","description":"Denies the get_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["get_credential"]}},"deny-get-designs-dir":{"identifier":"deny-get-designs-dir","description":"Denies the get_designs_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["get_designs_dir"]}},"deny-get-library-models":{"identifier":"deny-get-library-models","description":"Denies the get_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["get_library_models"]}},"deny-get-print-history":{"identifier":"deny-get-print-history","description":"Denies the get_print_history command without any pre-configured scope.","commands":{"allow":[],"deny":["get_print_history"]}},"deny-get-printer-configs":{"identifier":"deny-get-printer-configs","description":"Denies the get_printer_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_configs"]}},"deny-get-printer-status":{"identifier":"deny-get-printer-status","description":"Denies the get_printer_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_printer_status"]}},"deny-get-queue":{"identifier":"deny-get-queue","description":"Denies the get_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["get_queue"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-has-credential":{"identifier":"deny-has-credential","description":"Denies the has_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["has_credential"]}},"deny-list-profiles":{"identifier":"deny-list-profiles","description":"Denies the list_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_profiles"]}},"deny-makerworld-back":{"identifier":"deny-makerworld-back","description":"Denies the makerworld_back command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_back"]}},"deny-makerworld-forward":{"identifier":"deny-makerworld-forward","description":"Denies the makerworld_forward command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_forward"]}},"deny-makerworld-reload":{"identifier":"deny-makerworld-reload","description":"Denies the makerworld_reload command without any pre-configured scope.","commands":{"allow":[],"deny":["makerworld_reload"]}},"deny-mcp-rotate-token":{"identifier":"deny-mcp-rotate-token","description":"Denies the mcp_rotate_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_rotate_token"]}},"deny-mcp-set-enabled":{"identifier":"deny-mcp-set-enabled","description":"Denies the mcp_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_set_enabled"]}},"deny-mcp-status":{"identifier":"deny-mcp-status","description":"Denies the mcp_status command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_status"]}},"deny-mcp-token":{"identifier":"deny-mcp-token","description":"Denies the mcp_token command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_token"]}},"deny-navigate-makerworld":{"identifier":"deny-navigate-makerworld","description":"Denies the navigate_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["navigate_makerworld"]}},"deny-open-library-model":{"identifier":"deny-open-library-model","description":"Denies the open_library_model command without any pre-configured scope.","commands":{"allow":[],"deny":["open_library_model"]}},"deny-openscad-check-installed":{"identifier":"deny-openscad-check-installed","description":"Denies the openscad_check_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_check_installed"]}},"deny-openscad-extract-params":{"identifier":"deny-openscad-extract-params","description":"Denies the openscad_extract_params command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_extract_params"]}},"deny-openscad-render":{"identifier":"deny-openscad-render","description":"Denies the openscad_render command without any pre-configured scope.","commands":{"allow":[],"deny":["openscad_render"]}},"deny-pause-print":{"identifier":"deny-pause-print","description":"Denies the pause_print command without any pre-configured scope.","commands":{"allow":[],"deny":["pause_print"]}},"deny-probe-camera":{"identifier":"deny-probe-camera","description":"Denies the probe_camera command without any pre-configured scope.","commands":{"allow":[],"deny":["probe_camera"]}},"deny-read-model-file":{"identifier":"deny-read-model-file","description":"Denies the read_model_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_model_file"]}},"deny-read-text-file":{"identifier":"deny-read-text-file","description":"Denies the read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text_file"]}},"deny-remove-from-queue":{"identifier":"deny-remove-from-queue","description":"Denies the remove_from_queue command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_from_queue"]}},"deny-report-makerworld-import-attempt":{"identifier":"deny-report-makerworld-import-attempt","description":"Denies the report_makerworld_import_attempt command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_import_attempt"]}},"deny-report-makerworld-page":{"identifier":"deny-report-makerworld-page","description":"Denies the report_makerworld_page command without any pre-configured scope.","commands":{"allow":[],"deny":["report_makerworld_page"]}},"deny-resume-print":{"identifier":"deny-resume-print","description":"Denies the resume_print command without any pre-configured scope.","commands":{"allow":[],"deny":["resume_print"]}},"deny-search-library-models":{"identifier":"deny-search-library-models","description":"Denies the search_library_models command without any pre-configured scope.","commands":{"allow":[],"deny":["search_library_models"]}},"deny-search-makerworld":{"identifier":"deny-search-makerworld","description":"Denies the search_makerworld command without any pre-configured scope.","commands":{"allow":[],"deny":["search_makerworld"]}},"deny-set-active-view":{"identifier":"deny-set-active-view","description":"Denies the set_active_view command without any pre-configured scope.","commands":{"allow":[],"deny":["set_active_view"]}},"deny-set-default-printer":{"identifier":"deny-set-default-printer","description":"Denies the set_default_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["set_default_printer"]}},"deny-sidecar":{"identifier":"deny-sidecar","description":"Denies the sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["sidecar"]}},"deny-slice-model":{"identifier":"deny-slice-model","description":"Denies the slice_model command without any pre-configured scope.","commands":{"allow":[],"deny":["slice_model"]}},"deny-start-print":{"identifier":"deny-start-print","description":"Denies the start_print command without any pre-configured scope.","commands":{"allow":[],"deny":["start_print"]}},"deny-store-credential":{"identifier":"deny-store-credential","description":"Denies the store_credential command without any pre-configured scope.","commands":{"allow":[],"deny":["store_credential"]}},"deny-studio-files":{"identifier":"deny-studio-files","description":"Denies the studio_files command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_files"]}},"deny-studio-flush-failed":{"identifier":"deny-studio-flush-failed","description":"Denies the studio_flush_failed command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flush_failed"]}},"deny-studio-flush-started":{"identifier":"deny-studio-flush-started","description":"Denies the studio_flush_started command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flush_started"]}},"deny-studio-flushed":{"identifier":"deny-studio-flushed","description":"Denies the studio_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_flushed"]}},"deny-studio-import-file":{"identifier":"deny-studio-import-file","description":"Denies the studio_import_file command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_import_file"]}},"deny-studio-open-files-privacy":{"identifier":"deny-studio-open-files-privacy","description":"Denies the studio_open_files_privacy command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_files_privacy"]}},"deny-studio-open-in-bambu":{"identifier":"deny-studio-open-in-bambu","description":"Denies the studio_open_in_bambu command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_open_in_bambu"]}},"deny-studio-reveal":{"identifier":"deny-studio-reveal","description":"Denies the studio_reveal command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_reveal"]}},"deny-studio-save-bytes":{"identifier":"deny-studio-save-bytes","description":"Denies the studio_save_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["studio_save_bytes"]}},"deny-switch-printer":{"identifier":"deny-switch-printer","description":"Denies the switch_printer command without any pre-configured scope.","commands":{"allow":[],"deny":["switch_printer"]}},"deny-sync-makerworld-webview":{"identifier":"deny-sync-makerworld-webview","description":"Denies the sync_makerworld_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["sync_makerworld_webview"]}},"deny-update-check":{"identifier":"deny-update-check","description":"Denies the update_check command without any pre-configured scope.","commands":{"allow":[],"deny":["update_check"]}},"deny-update-install":{"identifier":"deny-update-install","description":"Denies the update_install command without any pre-configured scope.","commands":{"allow":[],"deny":["update_install"]}},"deny-update-printer-config":{"identifier":"deny-update-printer-config","description":"Denies the update_printer_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_printer_config"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-write-text-file":{"identifier":"deny-write-text-file","description":"Denies the write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text_file"]}}},"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","allow-supports-multiple-windows"]},"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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"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-icon-with-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-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_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-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_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-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"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-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"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-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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},"log":{"default_permission":{"identifier":"default","description":"Allows the log command","permissions":["allow-log"]},"permissions":{"allow-log":{"identifier":"allow-log","description":"Enables the log command without any pre-configured scope.","commands":{"allow":["log"],"deny":[]}},"deny-log":{"identifier":"deny-log","description":"Denies the log command without any pre-configured scope.","commands":{"allow":[],"deny":["log"]}}},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index c385bf2d..ca491b65 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-studio-flush-started","allow-studio-flushed","allow-studio-open-files-privacy","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Capability for the local main application webview","local":true,"webviews":["main"],"permissions":["core:default","log:default","opener:default","notification:default","dialog:default","allow-get-app-state","allow-set-active-view","allow-sync-makerworld-webview","allow-navigate-makerworld","allow-search-makerworld","allow-makerworld-back","allow-makerworld-forward","allow-makerworld-reload","allow-download-model","allow-report-makerworld-page","allow-report-makerworld-import-attempt","allow-connect-printer","allow-disconnect-printer","allow-get-printer-status","allow-probe-camera","allow-pause-print","allow-resume-print","allow-start-print","allow-list-profiles","allow-slice-model","allow-read-model-file","allow-read-text-file","allow-write-text-file","allow-add-to-queue","allow-get-queue","allow-remove-from-queue","allow-get-print-history","allow-mcp-status","allow-mcp-set-enabled","allow-mcp-token","allow-mcp-rotate-token","allow-design-build","allow-design-cancel","allow-design-list","allow-design-lineage","allow-design-get","allow-design-preview","allow-design-approve","allow-design-export","allow-design-record-print","allow-agent-status","allow-agent-set-api-key","allow-agent-clear-api-key","allow-agent-set-model","allow-agent-set-base-url","allow-agent-history","allow-agent-new-conversation","allow-agent-send","allow-agent-cancel","allow-delete-print-history-item","allow-get-library-models","allow-search-library-models","allow-delete-library-model","allow-open-library-model","allow-openscad-check-installed","allow-openscad-extract-params","allow-openscad-render","allow-get-designs-dir","allow-store-credential","allow-get-credential","allow-delete-credential","allow-has-credential","allow-add-printer-config","allow-update-printer-config","allow-delete-printer-config","allow-get-printer-configs","allow-set-default-printer","allow-connect-printer-by-config","allow-switch-printer","allow-get-settings","allow-update-settings","allow-bambu-studio-status","allow-choose-bambu-studio","allow-clear-bambu-studio","allow-update-check","allow-update-install","allow-studio-files","allow-studio-import-file","allow-studio-save-bytes","allow-studio-reveal","allow-studio-open-in-bambu","allow-studio-flush-started","allow-studio-flush-failed","allow-studio-flushed","allow-studio-open-files-privacy","allow-sidecar"]},"makerworld-remote":{"identifier":"makerworld-remote","description":"The remote MakerWorld webview may only report the page it shows and the result of an import. It cannot call any other app command.","remote":{"urls":["https://makerworld.com/*","https://*.makerworld.com/*"]},"local":false,"webviews":["makerworld"],"permissions":["allow-report-makerworld-page","allow-report-makerworld-import-attempt"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index fb682561..3aec35cc 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -770,6 +770,12 @@ "const": "allow-studio-files", "markdownDescription": "Enables the studio_files command without any pre-configured scope." }, + { + "description": "Enables the studio_flush_failed command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-flush-failed", + "markdownDescription": "Enables the studio_flush_failed command without any pre-configured scope." + }, { "description": "Enables the studio_flush_started command without any pre-configured scope.", "type": "string", @@ -1280,6 +1286,12 @@ "const": "deny-studio-files", "markdownDescription": "Denies the studio_files command without any pre-configured scope." }, + { + "description": "Denies the studio_flush_failed command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-flush-failed", + "markdownDescription": "Denies the studio_flush_failed command without any pre-configured scope." + }, { "description": "Denies the studio_flush_started command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json index fb682561..3aec35cc 100644 --- a/src-tauri/gen/schemas/linux-schema.json +++ b/src-tauri/gen/schemas/linux-schema.json @@ -770,6 +770,12 @@ "const": "allow-studio-files", "markdownDescription": "Enables the studio_files command without any pre-configured scope." }, + { + "description": "Enables the studio_flush_failed command without any pre-configured scope.", + "type": "string", + "const": "allow-studio-flush-failed", + "markdownDescription": "Enables the studio_flush_failed command without any pre-configured scope." + }, { "description": "Enables the studio_flush_started command without any pre-configured scope.", "type": "string", @@ -1280,6 +1286,12 @@ "const": "deny-studio-files", "markdownDescription": "Denies the studio_files command without any pre-configured scope." }, + { + "description": "Denies the studio_flush_failed command without any pre-configured scope.", + "type": "string", + "const": "deny-studio-flush-failed", + "markdownDescription": "Denies the studio_flush_failed command without any pre-configured scope." + }, { "description": "Denies the studio_flush_started command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8ef0e367..2097d711 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -159,6 +159,7 @@ pub fn run() { studio::files::studio_reveal, studio::files::studio_open_in_bambu, studio::quit::studio_flush_started, + studio::quit::studio_flush_failed, studio::quit::studio_flushed, studio::quit::studio_open_files_privacy, sidecar::sidecar, diff --git a/src-tauri/src/studio/quit.rs b/src-tauri/src/studio/quit.rs index 084260bb..b7b8d5d9 100644 --- a/src-tauri/src/studio/quit.rs +++ b/src-tauri/src/studio/quit.rs @@ -1,8 +1,9 @@ //! A normal quit, or closing the window, waits for the window to save what the person typed but has not saved yet. //! The window saves its draft a moment after typing pauses, so text typed just before a quit would be lost without //! this. The window says at once that it is saving, and then the quit waits for it to finish, as a send on its way -//! must end first. A window that does not answer, as one that is gone, holds the quit only briefly. A restart for an -//! update cannot be held: Tauri ignores a hold on it, and the update waits for idle. +//! must end first. A window that could not save a draft calls the quit off, so the app stays open with the draft. A +//! window that does not answer, as one that is gone, holds the quit only briefly. A restart for an update cannot be +//! held: Tauri ignores a hold on it, and the update waits for idle. use std::sync::Mutex; use std::time::Duration; @@ -41,6 +42,15 @@ pub struct QuitFlush { /// The window started saving. started: Notify, saved: Notify, + /// The window could not save a draft. + failed: Notify, +} + +/// What the window answered. +enum Answer { + Started, + Saved, + Failed, } impl QuitFlush { @@ -63,26 +73,36 @@ impl QuitFlush { drop(stage); let app = app.clone(); tauri::async_runtime::spawn(async move { - if app.emit(FLUSH, ()).is_ok() { - let flush = app.state::(); - let answer = tokio::time::timeout(FLUSH_WAIT, async { + let saved = if app.emit(FLUSH, ()).is_ok() { + let state = app.state::(); + let flush: &QuitFlush = &state; + let answer = |started: bool| async move { tokio::select! { - _ = flush.saved.notified() => true, - _ = flush.started.notified() => false, + _ = flush.saved.notified() => Answer::Saved, + _ = flush.failed.notified() => Answer::Failed, + _ = flush.started.notified(), if !started => Answer::Started, } - }) - .await; - // The window is saving: wait until it is done. - if answer == Ok(false) { - let _ = tokio::time::timeout(FLUSH_HOLD, flush.saved.notified()).await; + }; + match tokio::time::timeout(FLUSH_WAIT, answer(false)).await { + // The window is saving: wait until it is done, or says it could not save. + Ok(Answer::Started) => !matches!(tokio::time::timeout(FLUSH_HOLD, answer(true)).await, Ok(Answer::Failed)), + Ok(Answer::Failed) => false, + Ok(Answer::Saved) | Err(_) => true, } - } - let held = match std::mem::replace(&mut *app.state::().stage.lock().unwrap_or_else(|e| e.into_inner()), Stage::Saved) { + } else { + true + }; + // A draft the window could not save calls the quit or close off, so nothing is lost, and the next one asks + // the window again. + let next = if saved { Stage::Saved } else { Stage::Idle }; + let held = match std::mem::replace(&mut *app.state::().stage.lock().unwrap_or_else(|e| e.into_inner()), next) { Stage::Saving(held) => held, Stage::Idle | Stage::Saved => Vec::new(), }; - for then in held { - then(); + if saved { + for then in held { + then(); + } } }); true @@ -95,6 +115,12 @@ pub fn studio_flush_started(flush: State<'_, QuitFlush>) { flush.started.notify_one(); } +/// The window's last answer when a draft could not be saved: the quit or close is called off. +#[tauri::command] +pub fn studio_flush_failed(flush: State<'_, QuitFlush>) { + flush.failed.notify_one(); +} + /// The window's last answer: everything is saved. #[tauri::command] pub fn studio_flushed(flush: State<'_, QuitFlush>) { @@ -157,6 +183,24 @@ mod tests { assert!(started.elapsed() > FLUSH_WAIT, "held past the first wait, after {:?}", started.elapsed()); } + #[test] + fn a_save_that_failed_cancels_the_quit_and_a_later_quit_asks_again() { + let app = tauri::test::mock_app(); + app.manage(QuitFlush::default()); + let (done, finished) = std::sync::mpsc::channel(); + let again = done.clone(); + assert!(QuitFlush::hold(app.handle(), move |_| done.send(()).unwrap())); + std::thread::sleep(Duration::from_millis(50)); + studio_flush_started(app.state::()); + studio_flush_failed(app.state::()); + assert!(finished.recv_timeout(Duration::from_secs(1)).is_err(), "the app stays open"); + // A later quit holds and asks the window again, as nothing was saved. + std::thread::sleep(Duration::from_millis(100)); + assert!(QuitFlush::hold(app.handle(), move |_| again.send(()).unwrap()), "the next quit waits for the window"); + studio_flushed(app.state::()); + finished.recv_timeout(FLUSH_WAIT).expect("the next quit goes on once the window saved"); + } + #[test] fn the_windows_answer_ends_the_wait_at_once() { let app = tauri::test::mock_app(); diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 009eed82..cdb9344c 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -827,13 +827,15 @@ describe('StudioApp', () => { expect(screen.queryByTestId('attachments')).toBeNull() }) - it('waits for every screen\'s save before it says a quit may go on, even when another screen\'s save fails', async () => { + it('saves every screen before a quit goes on, retrying a save the engine restart refused once the engine is back', async () => { + let engineBack = false let releaseB: () => void = () => {} - mockSidecar( + const store = mockSidecar( { 'drafts.set': (params, real) => { - if (params.key === projectA.id) return Promise.reject('The engine is starting.') - return new Promise((resolve) => (releaseB = () => resolve(real()))) + if (params.key === projectA.id && !engineBack) return Promise.reject('The engine restarted before it answered. Try again.') + if (params.key === projectB.id) return new Promise((resolve) => (releaseB = () => resolve(real()))) + return real() }, }, [projectA, projectB], @@ -846,13 +848,33 @@ describe('StudioApp', () => { await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project B')) type('a shelf') await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) - await pause(50) - const flushed = () => invokeMock.mock.calls.some(([command]) => command === 'studio_flushed') - expect(flushed()).toBe(false) await act(async () => releaseB()) - await waitFor(() => expect(flushed()).toBe(true)) + await pause(50) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + expect(said('studio_flushed')).toBe(false) + engineBack = true + await emit({ type: 'ready', version: '1.2.3' }) + await waitFor(() => expect(said('studio_flushed')).toBe(true)) + expect(store.drafts[projectA.id]).toMatchObject({ text: 'a hook' }) + expect(store.drafts[projectB.id]).toMatchObject({ text: 'a shelf' }) }) + it('keeps the app open and says why when a draft still cannot be saved at a quit', async () => { + mockSidecar( + { 'drafts.set': (params, real) => (params.key === projectA.id ? Promise.reject('The engine stopped.') : real()) }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('a hook') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + await waitFor(() => expect(said('studio_flush_failed')).toBe(true), { timeout: 15_000 }) + expect(said('studio_flushed')).toBe(false) + expect(screen.getByRole('alert').textContent).toContain('could not be saved') + }, 20_000) + it('waits for a pasted picture still being kept before it says a quit may go on, and saves it with the draft', async () => { let keep: () => void = () => {} const store = mockSidecar({ 'drafts.picture': () => new Promise((resolve) => (keep = () => resolve('/state/pictures/1/shot.png'))) }, [projectA, projectB]) diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 386e451a..5fbb11e1 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -24,6 +24,10 @@ const DRAFT_PAUSE_MS = 400 const EMPTY: Draft = { text: '', attachments: [] } +/** How often a quit tries to save a draft, and how long it waits between tries for the engine to come back. */ +const QUIT_SAVE_TRIES = 4 +const QUIT_RETRY_MS = 3000 + /** * A screen's unsent message as the window holds it. The sidecar owns the draft and the send: the window only knows * whether the screen changed since its last completed save, and that save's revision, which a send names. @@ -103,6 +107,8 @@ export default function StudioApp() { const screens = useRef>({}) /** The open screen's save that waits for typing to pause. */ const pending = useRef<{ key: string; timer: number } | null>(null) + /** What waits for the engine's next start, as a quit's save the engine refused while it restarted. */ + const readyWaiters = useRef<(() => void)[]>([]) /** Pastes whose pictures are still being kept, so a quit waits for them. */ const pastes = useRef(new Set>()) /** The previews of pasted pictures, by path, freed when the window closes. */ @@ -247,6 +253,7 @@ export default function StudioApp() { setEngine(`Engine ${event.version}`) // A check that failed while the engine started again is asked again, so Send does not stay off. checkReady() + for (const wake of readyWaiters.current.splice(0)) wake() if (!stateRead) void readState() // An engine that started again holds what a crash cut off from the window, such as a turn's last entry, so // the open chat is read again. The changes that arrive meanwhile are kept, as when it first opened. @@ -338,14 +345,25 @@ export default function StudioApp() { } // A quit or the window's close waits for pasted pictures still being kept and each send on its way, then saves every - // screen, those a send just made included, and only then lets the quit go on. Rust holds the quit meanwhile. One save - // that fails does not end the wait for the others. + // screen, those a send just made included. Rust holds the quit meanwhile. A save the engine refused, as while it + // restarts, is tried again once the engine is back. A draft that still cannot be saved calls the quit off. useEffect(() => { const unlisten = onQuitFlush(async () => { clearPause() await Promise.allSettled([...pastes.current]) await Promise.allSettled(Object.values(screens.current).map((screen) => screen.sending)) - await Promise.allSettled(Object.keys(screens.current).map((key) => flush(key))) + for (let tries = 1; ; tries++) { + const saves = await Promise.allSettled(Object.keys(screens.current).map((key) => flush(key))) + if (saves.every((save) => save.status === 'fulfilled')) return + if (tries === QUIT_SAVE_TRIES) { + setError('A draft could not be saved, so Materialize 3D stayed open. Quit again once the engine is back.') + throw new Error('a draft could not be saved') + } + await new Promise((wake) => { + readyWaiters.current.push(wake) + window.setTimeout(wake, QUIT_RETRY_MS) + }) + } }) return () => { void unlisten.then((stop) => stop()) diff --git a/src/studio/api.ts b/src/studio/api.ts index 877ca4d7..84fa84d7 100644 --- a/src/studio/api.ts +++ b/src/studio/api.ts @@ -11,12 +11,16 @@ export const onSidecarEvent = (handler: (event: SidecarEvent) => void) => /** * Rust asks for this as the app quits or the window closes. The window says at once that it is saving, so Rust holds - * the quit for as long as that takes, then says it is done. + * the quit for as long as that takes. Then it says it is done, or that a draft could not be saved, which calls the + * quit off. */ export const onQuitFlush = (save: () => Promise) => listen('studio://flush', () => { void invoke('studio_flush_started') - void save().finally(() => invoke('studio_flushed')) + void save().then( + () => invoke('studio_flushed'), + () => invoke('studio_flush_failed'), + ) }) /** Opens the macOS setting that lets the app read Documents. */ From 176fde99a23d7034275ecb57f5d1fcc1f7c7a92c Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 20:10:41 -0600 Subject: [PATCH 58/59] fix(studio): each quit try waits for the sends on their way before it saves From Sol's review of 4798ac3: a send started while the quit waited to try again kept its screen from saving, so the try reported every save done and the quit went on with the text typed after that send unsaved. Each try now waits for every send first. The test failed first. Co-Authored-By: Claude Opus 5.5 --- src/studio/StudioApp.test.tsx | 30 ++++++++++++++++++++++++++++++ src/studio/StudioApp.tsx | 4 +++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index cdb9344c..5ad64559 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -859,6 +859,36 @@ describe('StudioApp', () => { expect(store.drafts[projectB.id]).toMatchObject({ text: 'a shelf' }) }) + it('saves text typed during a send that started while a quit waited to retry, before the quit goes on', async () => { + let engineBack = false + const sending = held() + const store = mockSidecar( + { + 'drafts.set': (_, real) => (engineBack ? real() : Promise.reject('The engine restarted before it answered. Try again.')), + 'chat.send': sending.call, + }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('a hook') + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + await pause(50) + // The engine is back before its ready event reaches the window. The person sends, then types the next message. + engineBack = true + fireEvent.click(screen.getByTestId('send')) + await waitFor(() => expect(sends()).toHaveLength(1)) + type('a hook, in black') + await emit({ type: 'ready', version: '1.2.3' }) + await pause(50) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + expect(said('studio_flushed')).toBe(false) + await sending.release() + await waitFor(() => expect(said('studio_flushed')).toBe(true)) + expect(store.drafts[projectA.id]).toMatchObject({ text: 'a hook, in black' }) + }) + it('keeps the app open and says why when a draft still cannot be saved at a quit', async () => { mockSidecar( { 'drafts.set': (params, real) => (params.key === projectA.id ? Promise.reject('The engine stopped.') : real()) }, diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 5fbb11e1..0bc3950d 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -351,8 +351,10 @@ export default function StudioApp() { const unlisten = onQuitFlush(async () => { clearPause() await Promise.allSettled([...pastes.current]) - await Promise.allSettled(Object.values(screens.current).map((screen) => screen.sending)) for (let tries = 1; ; tries++) { + // Each try waits for every send on its way first, those started while it waited to try again included, as a + // screen does not save while its send is on its way. + await Promise.allSettled(Object.values(screens.current).map((screen) => screen.sending)) const saves = await Promise.allSettled(Object.keys(screens.current).map((key) => flush(key))) if (saves.every((save) => save.status === 'fulfilled')) return if (tries === QUIT_SAVE_TRIES) { From cca20355dae56d42d4cf50f252a8eaae8e2c3463 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 20:15:10 -0600 Subject: [PATCH 59/59] fix(studio): a quit is done only when no screen sends and every screen holds what the store holds From a review of 176fde9: a Send clicked while the quit waited for a save already on its way still made that try skip the screen, and the quit went on with the newer text unsaved. The quit now goes round again until no screen sends and none changed since its last save, and counts only failed saves toward calling the quit off. The test failed first. Co-Authored-By: Claude Opus 5.5 --- src/studio/StudioApp.test.tsx | 29 +++++++++++++++++++++++++++++ src/studio/StudioApp.tsx | 14 +++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/studio/StudioApp.test.tsx b/src/studio/StudioApp.test.tsx index 5ad64559..a5b3517c 100644 --- a/src/studio/StudioApp.test.tsx +++ b/src/studio/StudioApp.test.tsx @@ -889,6 +889,35 @@ describe('StudioApp', () => { expect(store.drafts[projectA.id]).toMatchObject({ text: 'a hook, in black' }) }) + it('saves text typed during a send clicked while the quit waited for a save already on its way', async () => { + let releaseSave: () => void = () => {} + let saves = 0 + const sending = held() + const store = mockSidecar( + { + 'drafts.set': (_, real) => (++saves === 1 ? new Promise((resolve) => (releaseSave = () => resolve(real()))) : real()), + 'chat.send': sending.call, + }, + [projectA, projectB], + ) + render() + fireEvent.click(await screen.findByText('Project A')) + await waitFor(() => expect(screen.getByRole('banner').textContent).toBe('Project A')) + type('a hook') + // The pause after typing starts the save, and the quit comes while it is on its way. + await waitFor(() => expect(sidecarCalls('drafts.set')).toHaveLength(1)) + await act(async () => listeners.flush.forEach((listener) => listener({ payload: null }))) + fireEvent.click(screen.getByTestId('send')) + type('a hook, in black') + await act(async () => releaseSave()) + await pause(50) + const said = (command: string) => invokeMock.mock.calls.some(([name]) => name === command) + expect(said('studio_flushed')).toBe(false) + await sending.release() + await waitFor(() => expect(said('studio_flushed')).toBe(true)) + expect(store.drafts[projectA.id]).toMatchObject({ text: 'a hook, in black' }) + }) + it('keeps the app open and says why when a draft still cannot be saved at a quit', async () => { mockSidecar( { 'drafts.set': (params, real) => (params.key === projectA.id ? Promise.reject('The engine stopped.') : real()) }, diff --git a/src/studio/StudioApp.tsx b/src/studio/StudioApp.tsx index 0bc3950d..00fd8893 100644 --- a/src/studio/StudioApp.tsx +++ b/src/studio/StudioApp.tsx @@ -351,13 +351,17 @@ export default function StudioApp() { const unlisten = onQuitFlush(async () => { clearPause() await Promise.allSettled([...pastes.current]) - for (let tries = 1; ; tries++) { - // Each try waits for every send on its way first, those started while it waited to try again included, as a - // screen does not save while its send is on its way. + // It is done only when no screen sends and each holds what the store holds. A send or an edit that came while it + // saved, so a screen skipped or changed meanwhile, means another round. A save that failed is tried again once + // the engine is back, a few times, before the quit is called off. + for (let failures = 0; ; ) { await Promise.allSettled(Object.values(screens.current).map((screen) => screen.sending)) const saves = await Promise.allSettled(Object.keys(screens.current).map((key) => flush(key))) - if (saves.every((save) => save.status === 'fulfilled')) return - if (tries === QUIT_SAVE_TRIES) { + const failed = saves.some((save) => save.status === 'rejected') + const settled = Object.values(screens.current).every((screen) => screen.sending === undefined && !changed(screen)) + if (!failed && settled) return + if (!failed) continue + if (++failures === QUIT_SAVE_TRIES) { setError('A draft could not be saved, so Materialize 3D stayed open. Quit again once the engine is back.') throw new Error('a draft could not be saved') }