openpencil/desktop/capabilities/default.json

71 lines
2.1 KiB
JSON
Raw Normal View History

{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
fix: protect unsaved documents and defer credential access (#713) * fix(app): protect unsaved documents when closing Mark tabs with unsaved content updates and ask whether to save before closing them. The prompt now covers tab closes, the desktop window close button, and the application Quit action, which previously discarded work when autosave had no writable target. Track a content revision separately from scene and recovery versions so a save only clears the indicator when it wrote the revision it captured. Cancelled pickers, failed writes, and edits made during a save keep the document open. Desktop uses the platform alert; the browser keeps the styled dialog. The desktop menu replaces the predefined Quit item so the accelerator and Dock-independent quit path request confirmation instead of exiting. * fix(ai): resolve credentials only when used Opening a document, creating a chat, or browsing chat history connected the provider and read saved secrets, which triggered system credential prompts without user intent. Startup now reads credential status only, migration runs inside the first explicit resolution, and the chat panel initializes local history without creating a transport. Stock-photo keys resolve per search instead of at settings refresh, and credentials still marked legacy count as configured so upgrading does not appear to lose them. * refactor(ai): export diagnostics from Settings only Chat kept its own debug log, copied mixed app-wide usage into a conversation export, and reported a missing cache rate as zero. Remove that surface and record AI requests, model steps, and tool activity as correlated diagnostic events instead. Settings remains the single export location, usage summaries can now distinguish unreported telemetry from zero, and transcript or tool payloads are no longer part of the export. * fix(ai): clear legacy credentials for real Clearing a Pexels, Unsplash, or provider key only removed the current store entry. A value that still lived in legacy storage kept the key configured, so a later search migrated and used the credential the user had just removed. Migrate before mutating so clearing also removes the legacy value, and share one in-flight migration so the media and provider paths cannot migrate the same plaintext twice. * fix(ai): scope credential migration per source Sharing one migration promise process-wide let a second storage return the first migration's result, leaving its own legacy keys unmigrated while reporting success. Track in-flight migrations per storage and serialize them, because every migration writes to the same store and concurrent runs could overwrite each other. * fix(app): destroy the window after a confirmed close Tauri's onCloseRequested helper destroys the window itself when a handler returns without preventing the event. Approving a close therefore invoked plugin:window|destroy, which the capability set did not grant, so the window stayed open with a permission error after saving. Always intercept the request and destroy the window explicitly once the choice is confirmed, and grant core:window:allow-destroy in place of the now-unused close permission. * fix(app): show a filled dot for unsaved tabs The unsaved indicator used a stroked Lucide circle whose fill attribute kept it an empty outline, reading as a disabled control. Draw the indicator as a filled accent dot matching the status dots used elsewhere in the app. * refactor(app): focus the unsaved prompt with VueUse Replace the manual watcher, nextTick, and component $el focus with useFocus, which focuses the Save button when the dialog mounts. Assert the focus in the close-protection test so the Return-saves behavior stays covered. * refactor(app): route Quit through the shared menu channel The Quit item emitted a bespoke app:request-exit event and the close module listened for it, while every other native item travels as a menu-event id dispatched by the shell and editor menu composables. Emit menu-event "quit" for both the Quit item and the platform exit request, handle it in useShellMenu beside check-updates, and share one confirmAppExit so window closes and app exits agree on a single approval. * refactor(app): generate the macOS app menu entries The application menu hardcoded its labels and the Quit accelerator in Rust while every other menu entry is generated from APP_MENU_SCHEMA. Move the custom app entries (About, Check for Updates, Quit) into APP_MENU_APP_ITEMS and emit desktop/generated/app-menu.json, keyed by id so the native builder cannot silently drop a label. Placement stays in Rust because the OS-predefined items sit between them, and the menu title now comes from the packaged product name. * build(tauri-menu): check generated menus against the schema The generated menu files are committed but nothing verified them, so a schema edit could silently leave desktop/generated stale until the next release build regenerated it. Split the renderers from the write step, register the tool as a workspace so its dependencies resolve, and compare the committed files with the schema in a test that runs with the other tool checks. * fix(app): serialize exit confirmations The window close handler and the Quit item both call confirmAppExit, and the per-handler closing flag does not cover the two paths. Both could run close preparation, so an unsaved document could be prompted twice. Share one in-flight confirmation and clear it when it settles, so a cancelled or failed attempt still prompts again on the next request.
2026-09-17 12:25:15 +00:00
"core:window:allow-destroy",
"opener:default",
"dialog:default",
"dialog:allow-save",
"dialog:allow-open",
2026-05-01 09:18:53 +00:00
"updater:default",
"process:default",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-html",
"clipboard-manager:allow-write-text",
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-read-text-file",
"allow": [
{ "path": "$HOME/Library/Application Support/OpenPencil/mcp.json" },
{ "path": "$LOCALDATA/OpenPencil/mcp.json" },
{ "path": "$RUNTIME/openpencil/mcp.json" },
{ "path": "$HOME/.openpencil/mcp.json" }
]
},
{
"identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-exists",
"allow": [
{ "path": "$HOME/Library/Application Support/OpenPencil/mcp.json" },
{ "path": "$LOCALDATA/OpenPencil/mcp.json" },
{ "path": "$RUNTIME/openpencil/mcp.json" },
{ "path": "$HOME/.openpencil/mcp.json" }
]
},
{
"identifier": "fs:allow-mkdir",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-remove",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-watch",
"allow": [{ "path": "**" }]
},
"fs:allow-unwatch",
{
"identifier": "shell:allow-spawn",
"allow": [
{ "name": "claude-agent-acp", "cmd": "claude-agent-acp", "args": true },
{ "name": "codex-acp", "cmd": "codex-acp", "args": true },
{ "name": "gemini", "cmd": "gemini", "args": true },
{ "name": "openpencil-mcp-http", "cmd": "openpencil-mcp-http", "args": true },
{ "name": "openpencil-harness", "cmd": "openpencil-harness", "args": false },
{ "name": "cmd", "cmd": "cmd", "args": true }
]
},
"shell:allow-stdin-write",
"shell:allow-kill"
]
feat: open documents and layers from openpencil:// and web links (#708) * feat(desktop): register openpencil:// deep link scheme Signed-off-by: Marc Went <marc@went.io> * feat(desktop): parse openpencil://open?file&node links Signed-off-by: Marc Went <marc@went.io> * feat(desktop): queue openpencil:// links as pending opens Signed-off-by: Marc Went <marc@went.io> * fix(desktop): read cold-start deep links on windows/linux Signed-off-by: Marc Went <marc@went.io> * feat(app): resolve openpencil:// links and select the target node A link's file is repo-relative, so it is resolved against the paths of the open tabs and otherwise located once by the user through the dialog picker. Nothing else is read from disk and no fs scope is widened. The node is matched by exact name on the current page, selected and zoomed to; a missing node raises a notice instead of failing silently. Signed-off-by: Marc Went <marc@went.io> * docs: document the openpencil:// URL scheme Describe the link format, the relative-path rule, how the file is resolved against open tabs or a one-time picker, and that the scheme can only open a document and select a layer. Signed-off-by: Marc Went <marc@went.io> * test(app): cover cancelled deep-link picks Inject the file picker and open entry points into openDeepLink so the test can drive the branch where the picked file is not the requested one. The repository lint forbids module registry mocking, and the existing file batch helper takes its opener the same way. Reword the module comment: the opened file joins the recent-files list like any other opened file, and a one-segment file matches the first open tab whose path ends with it. Signed-off-by: Marc Went <marc@went.io> * docs: sharpen the URL scheme notes Selecting by name selects every layer with that name on the current page and zooms to the whole selection. Record that the first matching open tab wins, that path separators may stay literal in the query, and how the scheme reaches the app on each platform. Signed-off-by: Marc Went <marc@went.io> * refactor(app): keep the deep-link io type internal Nothing outside the module names the injected io type, so contextual typing at the call site is enough. Drop the redundant recording array from the cancelled pick test. Signed-off-by: Marc Went <marc@went.io> * fix(desktop): tag pending opens by producer The frontend classified a pending entry by the shape of its path, which called a canonicalized Windows path (`\\?\C:\…`) relative and sent a double-clicked document into the deep-link resolver. Rust now says which producer queued the entry, and the tail both producers shared moves into `queue_pending`. Signed-off-by: Marc Went <marc@went.io> * test(app): assert the opener receives the resolved path Signed-off-by: Marc Went <marc@went.io> * test(desktop): refuse a percent-encoded parent segment Signed-off-by: Marc Went <marc@went.io> * chore(desktop): relax the deep-link plugin pin Signed-off-by: Marc Went <marc@went.io> * chore(desktop): drop the unused deep-link capability Draining links is Rust-side, so the webview never calls `deep-link:allow-get-current`. Signed-off-by: Marc Went <marc@went.io> * fix(app): clamp link values in notices Signed-off-by: Marc Went <marc@went.io> * fix(desktop): pass deep links through the linux desktop entry The bundler's default desktop template writes `Exec={{exec}}` with no field code, so a Linux cold start from a deb, rpm or AppImage never receives the `openpencil://` link as an argument and `get_current()` has nothing to recover. Ship a custom template that is the bundler default plus `%U`, wired to both the deb and rpm bundlers (AppImage reuses the deb data dir). MIME types still come from `{{mime_type}}`, so the file associations are unchanged. Signed-off-by: Marc Went <marc@went.io> * feat(app): open documents from ?file= links in the browser The desktop build takes openpencil:// links; the web app had no equivalent. It now reads file and node off its own address bar on boot, fetches the document from an absolute https URL without credentials and without following redirects, selects the named layer through the same path the deep link uses, and strips both params so a reload does not re-open. Signed-off-by: Marc Went <marc@went.io> * fix(app): keep router state coherent when stripping web link params Rewriting history directly left the router's own record of the current URL pointing at the un-stripped one, so the next router.push wrote file and node back into the history entry. The strip is now an injected action that goes through router.replace, preserving the route, hash and every other query key. Also clamp the failure detail, take the last value of a repeated key like the desktop parser does, share deep-link's clamp instead of copying it, and report a failed fetch through toast.error. Signed-off-by: Marc Went <marc@went.io> * fix(app): resolve deep links by filesystem case and bound remote fetches Deep links resolved their file by comparing path segments in JavaScript, which is case-sensitive: on macOS and Windows `Web/Design/hikyo.pen` and `web/design/hikyo.pen` name the same file, yet both the open-tab lookup and the picker check refused it and the link was cancelled. The comparison now goes through a `path_matches_suffix` Tauri command that canonicalizes the candidate and folds ASCII case on macOS and Windows while staying exact on Linux. `resolveDeepLinkFile` takes the comparator as an argument, so it stays testable without Tauri, and the rule is tested in `deep_link.rs`. An already open document is focused through `activateTabForPath` instead of `openFileFromPath`, which re-read the file from disk first and rejected the whole link when it had moved or lost its permissions since the tab opened it. The picker branch still opens the file, and a tab that closed between the snapshot and the activate falls back to opening it. A web link's `file` URL drops its fragment. The tab identity compares source URLs exactly, so two links to one document differing only in fragment opened two tabs. A document fetched from a URL is capped at 64 MiB, counted off the streamed body rather than the sender's `Content-Length`, with the request aborted the moment it goes over instead of buffering whatever the host decides to send. Draining the pending-open queue goes through `openDesignFileBatch`, the per-item catch every other open path already uses, so one failing entry no longer skips the rest of the batch. Signed-off-by: Marc Went <marc@went.io> * fix(desktop): match a deep-link suffix against the literal path too Canonicalizing the candidate resolves a symlink that sits inside the trailing segments, so a monorepo checkout where `packages/web` links to `../apps/web` would stop matching a link that spells the path the way the tab does. Compare both spellings: the canonical path keeps `..` and prefix symlinks working, the literal one keeps the path the user actually sees. Both inputs are already-open or user-picked paths, so trying the literal one grants nothing new. Signed-off-by: Marc Went <marc@went.io> * fix(app): cap the automation fetch and chain a caller's abort signal `openBrowserFileFromURL` replaced a caller-supplied `signal` with the one the size cap needs, so a caller could no longer cancel its own request. The two are chained instead: the caller's abort aborts the cap's controller, and an already-aborted signal is honoured before the fetch goes out. `handleOpenFile` in the automation bridge was the last fetch buffering an unbounded body. It reads a document the same way, so it gets the same 64 MiB ceiling, counted off the stream and aborted on overflow. Its relative-path resolution and its lack of a format assert are unchanged. `path_matches_suffix` runs `async`, so `canonicalize` cannot block the main thread on a stale network mount, and it now refuses an absolute or `..`-bearing suffix: `parse_open_url` already does, but this is the comparison every caller funnels through and an absolute suffix would otherwise match on its segments alone. The command itself gained tests over a real temp tree — exact match, the platform case rule, the symlinked trailing directory that motivated the literal fallback, a missing file, and the refusals. The docs and the module header claimed an opened file always joins the recent files list, in the same breath as saying an already open tab is focused without re-reading it. Only the former opens anything, so only the former touches the list. Signed-off-by: Marc Went <marc@went.io> * docs(changelog): note the 64 MiB ceiling on the automation bridge openFile Signed-off-by: Marc Went <marc@went.io> * fix(app): deliver cold-start deep links through the deep-link path macOS hands a launch `openpencil://` link to the app as `RunEvent::Opened` before the app's `setup` closure runs. Traced on a cold `open`: `RunEvent::Opened` at T+0.085 s, `setup` at T+0.342 s, and `on_open_url` never fired. The plugin's `deep-link://new-url` emit therefore reached no listener and the URL survived only in the plugin's `current`, which was drained under `#[cfg(any(windows, target_os = "linux"))]` on the assumption that macOS was unaffected. It is not: a cold link launched the app to an empty tab with no picker, no toast and no log line, while the same link fired at a running app worked. The drain now runs on every desktop platform; `register_all` stays gated, macOS does not support it. Nothing is queued twice. `RunEvent::Opened` is dispatched on the thread that runs `setup`, so a link cannot arrive between registering `on_open_url` and reading `current`, and anything later is no longer in `current`. A cold double-clicked document is unaffected: `current` now also yields its `file://` URL, and the `scheme == "openpencil"` filter in `queue_deep_links` drops it, leaving `queue_open_paths` the only producer for that path. The pending-open routing moves out of `WorkspaceView.vue` into `app/document/io/pending-open.ts`, so which entry reaches the deep-link resolver and which reaches the plain opener is unit-testable without mounting the view. A drain that fails wholesale — the `take_pending_open` invoke, the event binding — now raises a toast instead of only a console line; per-entry failures were already toasted. Signed-off-by: Marc Went <marc@went.io> * refactor(app): share one bounded body reader readBodyWithLimit reimplemented the chunked cap that vectorize's readBoundedResponse already applied, and it lived in the menu module while the automation bridge imported it from there. Move the reader to the browser document-io owner as readBoundedBody, returning bytes with an optional overflow hook and error message, and have both the document fetch and the vectorize providers use it. The automation bridge now opens a browser file through openBrowserFileFromURL instead of re-inlining fetch, cap and tab creation, so it also gets the same format check as the Tauri path, and the caller's abort signal is combined with the cap's controller through AbortSignal.any. * fix(app): report a failed tab activation activateTabForPath returned true after calling switchTab, but switchTab silently does nothing when the tab is gone. A tab that closed while the identity lookup awaited therefore looked focused, and the caller skipped opening the file, so the link did nothing at all. Return whether a tab was actually activated. * fix(app): translate the document link notices The four notices added for document links existed only in the English defaults, so a localized build showed English toasts. check:i18n does not cover the app-level notification catalog, which is why nothing caught it. Also correct the docs: a `.` segment is refused along with `..`, matching the matcher. * fix(desktop): refuse a dot segment in deep links The parser accepted `web/./design.pen` while path_ends_with_segments refuses `.`, so such a link was queued and could then never match an open tab or a picked file — it failed silently after asking the user to locate the file. Refuse `.` alongside `..` in the parser and drop the whitespace-only line left in the capability file. * refactor(app): tidy the document link plumbing Four smaller things from review: - A dismissed file picker is not a wrong file, so it no longer reports "expected a file ending in …", which named a file the user never chose. - Reuse es-toolkit's omit for stripping the link params, as the MCP settings form already does. - Drop the openDesignFileBatch re-export from menu/use.ts; nothing imports it from there. - Move the exact-name lookup out of the view: selectNodesByName lives with the other selection helpers and walks the graph directly, instead of building a whole FigmaAPI facade from the automation bridge to answer one query. * refactor(app): centralize focusing nodes The name lookup was a link-shaped helper in the selection domain, and it baked one strategy into the action. Split it into the two things a caller actually needs: focusNodes(ids) is the select-and-zoom primitive that share and collaboration references want, and focusNodesByName resolves an exact name on the current page first. The store dependency is a narrow interface, as with the viewport actions, so the action is unit-testable and stale ids can be ignored instead of selected. --------- Signed-off-by: Marc Went <marc@went.io> Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-18 11:45:49 +00:00
}