diff --git a/.gitignore b/.gitignore index 832edea4d..5927af979 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ src/test/container-*/**/src/**/README.md /src-tauri/target/ /frontend/dist/ /frontend/node_modules/ +/frontend/public/devcontainer-engine.js +/frontend/public/devcontainer-engine.js.map .deno/ diff --git a/ROADMAP.md b/ROADMAP.md index 03c441dcd..0f025d43a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,18 +21,32 @@ issue body; the steps below are the unit of PR-by-PR delivery. ## Phase 1 (cont.) — MVP -- [ ] **Step 5 — `apple_containers` impl**: `pull`, `create`, `start`, `exec`, +- [x] **Step 5 — `apple_containers` impl**: `pull`, `create`, `start`, `exec`, `logs`, `stop`, `remove`. Behind feature flag `apple-containers-live` for tests that drive the real `container` CLI. -- [ ] **Step 6 — FileHost bridge wired end-to-end**: WebView-side adapter +- [x] **Step 6 — FileHost bridge wired end-to-end**: WebView-side adapter (`frontend/src/devcontainer-engine/index.ts`) calls Tauri commands that read a real `.devcontainer/devcontainer.json`; the spec slice returns a fully substituted config to Rust. -- [ ] **Step 7 — Lifecycle orchestrator** in Rust: parsed config → +- [x] **Step 7 — Lifecycle orchestrator** in Rust: parsed config → `ContainerSpec` → `ContainerRuntime`. Run lifecycle hooks via `portable-pty`, stream logs/events to the WebView. -- [ ] **Step 8 — MVP UI**: dashboard, "Open folder", workspace detail with +- [x] **Step 8 — MVP UI**: dashboard, "Open folder", workspace detail with Up/Stop/Rebuild/Terminal/Logs (xterm.js). +- [x] **Step 8b — `build:` support**: `ContainerRuntime::build()` plus an + Apple Containers impl that shells out to `container build`, streams + stdout/stderr into the dashboard log pane, and surfaces captured + stderr on failure. Lifecycle dispatches build-vs-pull and emits a + `building` status. Paths in `build.dockerfile` / `build.context` + resolve relative to `.devcontainer/`, matching the upstream spec. + Synthesized tag is `devcontainer-:latest`. See + [docs/devcontainer-config-support.md](docs/devcontainer-config-support.md). +- [x] **Step 8c — `dockerComposeFile` policy**: explicitly rejected with + an actionable error. The app's container model is **one container + per workspace folder** (wikis, agents, databases, ML inference, + …); multi-container topologies are composed at the dashboard + level, not via Compose. This is a **product decision, not a + deferral** — Step 10's compose bullet is removed. ## Phase 1 cleanup @@ -46,8 +60,7 @@ issue body; the steps below are the unit of PR-by-PR delivery. - [ ] **Step 10 — Features + templates**: replace the Node `tar`/OCI logic with Rust (`oci-distribution`, `tar`, `flate2`). Templates browser. - `dockerComposeFile` support (when Apple Containers' compose story is - settled, or via a Rust compose shim). Dotfiles bootstrap. + Dotfiles bootstrap. ## Phase 3 — Other backends and platforms @@ -57,7 +70,6 @@ issue body; the steps below are the unit of PR-by-PR delivery. - Apple Containers programmatic API surface (Swift/C) — keep an eye out so `apple_containers.rs` can switch from CLI shelling to direct API calls. -- Compose semantics in Apple Containers. - OCI auth parity with the upstream CLI for Features. - Sync cadence with `devcontainers/cli`; the spec slice's directory layout is intentionally close to upstream so periodic merges are cheap. diff --git a/devcontainers-cli.code-workspace b/devcontainers-cli.code-workspace index 362d7c25b..5af6f36e7 100644 --- a/devcontainers-cli.code-workspace +++ b/devcontainers-cli.code-workspace @@ -2,6 +2,12 @@ "folders": [ { "path": "." + }, + { + "path": "../wiki3-app" + }, + { + "path": "../../Wiki3" } ] } \ No newline at end of file diff --git a/docs/building-on-macos.md b/docs/building-on-macos.md index 90e3e12a4..f6ae6c915 100644 --- a/docs/building-on-macos.md +++ b/docs/building-on-macos.md @@ -49,10 +49,25 @@ scripts/mac-dev.sh # == cd src-tauri && cargo tauri dev ``` -This honors `tauri.conf.json`'s `beforeDevCommand` (`yarn --cwd ../frontend +This honors `tauri.conf.json`'s `beforeDevCommand` (`yarn --cwd frontend dev` on `http://localhost:1420`), so Vite + the Rust host come up together with hot reload. +To crank up Rust-side logging, set `RUST_LOG` before launching: + +```sh +RUST_LOG=devcontainers_app_lib=debug scripts/mac-dev.sh +``` + +Lifecycle stages (`pull`, `build`, `create`, `start`, `exec`, hooks) emit +`tracing` events at `info`, with the `container` CLI's stderr captured at +`error` on failure. The same details are forwarded to the WebView as +`devcontainer://log` events so they show up in the in-app terminal. + +For what each `devcontainer.json` field maps to (and what's intentionally +unsupported, like `dockerComposeFile`), see +[devcontainer-config-support.md](devcontainer-config-support.md). + ## 4. Release build (`.app` / `.dmg`) `tauri.conf.json` has `"bundle": { "active": false, ... }`, so bundle @@ -86,3 +101,29 @@ Real signing/notarization is out of scope until later in the roadmap. Use [.devcontainer/](../.devcontainer/) when you want to reproduce the CI matrix — Rust lints, Deno bundle, spec typecheck — in a clean Linux env. Use the macOS host scripts above when you want to **run** the app. + +## Notes for future-us + +### How the WebView loads the engine bundle + +The deno-built spec slice (`dist/devcontainer-engine.js`) is copied by +[scripts/build-engine.ts](../scripts/build-engine.ts) into +`frontend/public/devcontainer-engine.js`. Files under `frontend/public/` +are served verbatim by Vite (and copied verbatim into `frontend/dist/` +at build time), which is what we want — the engine bundle has its own +Node polyfills baked in via esbuild, and we don't want Vite/Rollup +rewriting any of it. + +Vite 7 deliberately refuses to **`import()`** modules out of `/public/`, +even with `/* @vite-ignore */`, because public assets are not meant to go +through the module pipeline. So [frontend/src/main.ts](../frontend/src/main.ts) +`fetch()`s the bundle as text, wraps it in a `Blob`, and dynamic-imports +the resulting `blob:` URL. The blob URL is opaque to Vite and the +browser treats it as a fully-formed ES module. + +If you ever need to tighten the Tauri WebView CSP in +[src-tauri/tauri.conf.json](../src-tauri/tauri.conf.json), make sure +`script-src` keeps `blob:` (or `'unsafe-inline'` is already broad enough +to include it via the dev `csp_dev` override). Without it, the engine +dynamic import will fail in the packaged release build with a CSP +violation. diff --git a/docs/devcontainer-config-support.md b/docs/devcontainer-config-support.md new file mode 100644 index 000000000..cc40a4845 --- /dev/null +++ b/docs/devcontainer-config-support.md @@ -0,0 +1,131 @@ +# Supported `devcontainer.json` configuration + +This doc tracks what the Devcontainers.app host actually understands today, +and how it maps each top-level field onto the Apple `container` CLI. Keep +it in sync with [src-tauri/src/devcontainer/translate.rs](../src-tauri/src/devcontainer/translate.rs) +and [src-tauri/src/devcontainer/lifecycle.rs](../src-tauri/src/devcontainer/lifecycle.rs). + +## Container model: one container per repo + +The app is intentionally **one container per workspace folder**. A +"workspace" here is usually a wiki/repo, but can also be a long-running +service (agents, databases, ML inference, …). This shapes a few choices: + +- `dockerComposeFile` is **explicitly rejected** with a clear error. We do + not plan to add Compose support — multi-container topologies should be + expressed as multiple workspaces, each with its own `devcontainer.json`, + composed at the app/dashboard level rather than at the container layer. +- The synthesized image tag for `build`-based configs is + `devcontainer-:latest`, scoped to the workspace. +- Container names are derived from the workspace folder name via + `sanitize_entity_name`. + +## Image source: `image` *or* `build` (exactly one) + +### `image: ""` + +Pulled with `container image pull ` before create. Parsed via +`parse_image_ref` so registry / repository / tag / digest are tracked +separately. + +### `build: { ... }` + +Built locally with `container build` before create. Supported sub-fields: + +| Field | Behavior | +| ------------ | ---------------------------------------------------------------------- | +| `dockerfile` | Path **relative to `.devcontainer/`**. Defaults to `Dockerfile`. | +| `context` | Path **relative to `.devcontainer/`**. Defaults to `.` (the `.devcontainer/` folder itself). | +| `args` | Forwarded as `--build-arg KEY=VALUE`. Sorted for deterministic argv. | +| `target` | Forwarded as `--target `. | +| `cacheFrom` | Parsed but not yet forwarded — Apple `container build` has no flag. | + +Path resolution mirrors the upstream spec: both `dockerfile` and `context` +are anchored at the directory containing `devcontainer.json`. So in +`/repo/.devcontainer/devcontainer.json`: + +```jsonc +{ "build": { "dockerfile": "Dockerfile", "context": ".." } } +``` + +…builds from `/repo/` with `--file /repo/.devcontainer/Dockerfile`. + +The resolved `configFilePath` is forwarded from the JS engine bundle +(`frontend/src/devcontainer-engine/index.ts`) to Rust through +`ParsedDevContainer.config_file_path`, which is what the lifecycle uses +to anchor the relative paths. + +### Neither `image` nor `build` + +Validation rejects the config. There is no silent fallback to a default +base image — the previous `mcr.microsoft.com/devcontainers/base:ubuntu` +default has been removed. + +## Lifecycle: build vs pull dispatch + +`LifecycleOrchestrator::resolve_image` decides per-workspace what to do: + +``` +parsed.build.is_some() → emit "building" status + container build --tag devcontainer-:latest \ + --file \ + [--build-arg k=v]... \ + [--target stage] \ + + stream stdout/stderr line-by-line into the + dashboard log pane via LogChunk + on success → use the synthesized tag +parsed.image → emit "pulling" status + container image pull + on success → use the parsed ImageRef +``` + +After `resolve_image` returns an `ImageRef`, `to_container_spec` takes +that ref as a parameter — image resolution and spec translation are no +longer entangled. + +### Failure surfaces + +`apple_containers::build` captures stderr while it streams it. On a +non-zero exit, the error wraps the full `container build …` argv plus +the trimmed stderr, so the dashboard error pill shows what went wrong +without forcing the user to scroll the log. + +The `building` status flows through the `EventSink` seam exactly like +`pulling`, so xterm-side rendering and the test `CapturingSink` see the +same events. + +## Other top-level fields + +| Field | State | +| ---------------------------------- | --------------------------------------------------------------------- | +| `name` | Used to derive container name; sanitized. | +| `workspaceFolder`, `workspaceMount`| Mapped onto a host bind mount of the workspace dir. | +| `mounts` | Forwarded to `container run --mount`. | +| `containerEnv`, `remoteEnv` | Forwarded as `--env KEY=VALUE`. | +| `runArgs` | Appended verbatim to `container run`. | +| `forwardPorts` | Tracked, surfaced in the UI; no automatic publish yet. | +| `postCreateCommand`, `postStartCommand`, `postAttachCommand`, `initializeCommand`, `onCreateCommand`, `updateContentCommand` | Run via `portable-pty`; output streamed to the log pane. | +| `features` | Parsed but not yet installed. OCI-fetch + install layer pending. | +| `customizations` | Forwarded to the WebView; host ignores it. | +| `dockerComposeFile` | **Rejected** with an actionable error message. | + +## Tests guarding this behavior + +In [src-tauri/src/devcontainer/lifecycle.rs](../src-tauri/src/devcontainer/lifecycle.rs) `mod tests`: + +- `up_emits_pulling_creating_running_in_order` — happy path for `image`. +- `up_pull_failure_surfaces_error_status_and_log` — pull failure path. +- `up_with_dockerfile_build_invokes_runtime_build_and_emits_building_status` + — happy path for `build`, asserts the resolved Dockerfile/context paths + and the `devcontainer-:latest` tag, plus state sequence + `building → creating → created → running`. +- `up_with_build_failure_surfaces_error_status_and_log` — build failure + path: error message includes captured stderr, error status emitted. +- `up_with_compose_config_reports_unsupported_error` — compose rejection + message stays stable. + +In [src-tauri/src/container/apple_containers/cli.rs](../src-tauri/src/container/apple_containers/cli.rs): + +- `build_args_*` tests pin the exact argv ordering for `container build`, + including deterministic `--build-arg` sort order. diff --git a/frontend/package.json b/frontend/package.json index 59690810c..5db8f2b14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,10 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@tauri-apps/api": "^2.0.0" + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0" }, "devDependencies": { "typescript": "^5.9.3", diff --git a/frontend/src/devcontainer-engine/index.ts b/frontend/src/devcontainer-engine/index.ts index 432b460bc..e0dded246 100644 --- a/frontend/src/devcontainer-engine/index.ts +++ b/frontend/src/devcontainer-engine/index.ts @@ -5,9 +5,16 @@ * filesystem reads/writes to Tauri commands. The spec slice itself is pure * TS (no Node built-ins beyond `node:path` / `node:crypto` which Deno * inlines at bundle time). + * + * Step 6 of the conversion roadmap: this module also provides the + * end-to-end `loadDevContainerConfig` pipeline — read the workspace's + * `.devcontainer/devcontainer.json` through the `FileHost`, parse JSONC, + * run pre-container variable substitution, and post a runtime-agnostic + * parsed shape back to the Rust host. *--------------------------------------------------------------------------------------------*/ import * as posixPath from 'node:path'; +import * as jsonc from 'jsonc-parser'; import { CLIHostDocuments, @@ -24,7 +31,7 @@ import { beforeContainerSubstitute, type SubstitutionContext, } from '../../../spec/spec-common/variableSubstitution'; -import type { DevContainerConfig } from '../../../spec/spec-configuration/configuration'; +import type { DevContainerConfig, Mount } from '../../../spec/spec-configuration/configuration'; import { bridge } from '../lib/bridge'; export { @@ -51,3 +58,194 @@ export function tauriFileHost(): FileHost { toCommonURI: async () => undefined, }; } + +/** + * Runtime-agnostic shape posted to the Rust host. Mirrors + * `crate::devcontainer::translate::ParsedDevContainer` field-for-field; + * the Rust orchestrator translates this into a `ContainerSpec`. + * + * Field naming matches Rust's `serde(rename_all = "snake_case")` default + * since `tauri::command` rewrites camelCase JS keys to snake_case + * automatically. + */ +export interface DevContainerBuild { + dockerfile?: string; + context?: string; + args?: Record; + target?: string; +} + +export interface ParsedDevContainer { + name?: string; + image?: string; + build?: DevContainerBuild; + dockerComposeFile?: unknown; + /** + * Absolute filesystem path of the `devcontainer.json` we loaded, so + * the Rust host can resolve `build.dockerfile` / `build.context` + * relative to its parent directory (the `.devcontainer/` folder). + */ + configFilePath?: string; + workspaceFolder?: string; + workspaceMount?: string; + mounts: string[]; + forwardPorts: number[]; + remoteUser?: string; + containerEnv: Record; + remoteEnv: Record; + onCreateCommand?: string | string[]; + updateContentCommand?: string | string[]; + postCreateCommand?: string | string[]; + postStartCommand?: string | string[]; + postAttachCommand?: string | string[]; +} + +export interface LoadConfigResult { + configFilePath: string; + parsed: ParsedDevContainer; + raw: DevContainerConfig; +} + +/** + * Load, parse, and pre-container-substitute the `.devcontainer/devcontainer.json` + * for `workspacePath`. Returns `undefined` if no config file exists. + * + * The function is pure with respect to `fileHost`, so unit tests can pass + * an in-memory implementation without going through Tauri. + */ +export async function loadDevContainerConfig( + fileHost: FileHost, + workspacePath: string, + env: Record = {}, +): Promise { + const ws = workspaceFromPath(fileHost.path, workspacePath); + const uri = await getDevContainerConfigPathIn(fileHost, ws.configFolderPath); + if (!uri) { + return undefined; + } + const documents = createDocuments(fileHost); + const text = await documents.readDocument(uri); + if (text === undefined) { + return undefined; + } + const errors: jsonc.ParseError[] = []; + const tree = jsonc.parse(text, errors, { allowTrailingComma: true }); + if (errors.length > 0) { + const first = errors[0]; + throw new Error(`devcontainer.json parse error: ${jsonc.printParseErrorCode(first.error)} at offset ${first.offset}`); + } + const config = tree as DevContainerConfig; + (config as DevContainerConfig & { configFilePath?: unknown }).configFilePath = uri; + + const ctx: SubstitutionContext = { + platform: fileHost.platform, + configFile: uri, + localWorkspaceFolder: ws.rootFolderPath, + containerWorkspaceFolder: undefined, + env, + }; + const substituted = substitute(ctx, config); + + return { + configFilePath: uri.toString(), + parsed: toParsed(substituted, uri.fsPath), + raw: substituted, + }; +} + +/** + * Project a fully-substituted `DevContainerConfig` onto the minimal shape + * the Rust orchestrator consumes. Features, dockerComposeFile, and + * dockerFile-based configs land in later phases — for v1 the orchestrator + * only consumes image-based configs. + */ +export function toParsed(config: DevContainerConfig, configFilePath?: string): ParsedDevContainer { + const c = config as DevContainerConfig & { + image?: string; + build?: { + dockerfile?: string; + context?: string; + args?: Record; + target?: string; + }; + dockerComposeFile?: unknown; + mounts?: (Mount | string)[]; + forwardPorts?: (number | string)[]; + workspaceFolder?: string; + workspaceMount?: string; + remoteUser?: string; + containerEnv?: Record; + remoteEnv?: Record; + onCreateCommand?: string | string[]; + updateContentCommand?: string | string[]; + postCreateCommand?: string | string[]; + postStartCommand?: string | string[]; + postAttachCommand?: string | string[]; + }; + const mounts = (c.mounts ?? []).map((m) => + typeof m === 'string' ? m : mountToString(m), + ); + const forwardPorts = (c.forwardPorts ?? []) + .map((p) => (typeof p === 'number' ? p : Number.parseInt(p, 10))) + .filter((p) => Number.isFinite(p) && p > 0 && p < 65536); + const build: DevContainerBuild | undefined = c.build + ? { + dockerfile: c.build.dockerfile, + context: c.build.context, + target: c.build.target, + args: c.build.args + ? Object.fromEntries( + Object.entries(c.build.args).map(([k, v]) => [k, String(v)]), + ) + : undefined, + } + : undefined; + return { + name: c.name, + image: c.image, + build, + dockerComposeFile: c.dockerComposeFile, + configFilePath, + workspaceFolder: c.workspaceFolder, + workspaceMount: c.workspaceMount, + mounts, + forwardPorts, + remoteUser: c.remoteUser, + containerEnv: c.containerEnv ?? {}, + remoteEnv: c.remoteEnv ?? {}, + onCreateCommand: c.onCreateCommand, + updateContentCommand: c.updateContentCommand, + postCreateCommand: c.postCreateCommand, + postStartCommand: c.postStartCommand, + postAttachCommand: c.postAttachCommand, + }; +} + +function mountToString(m: Mount): string { + const parts: string[] = [`type=${m.type}`]; + if (m.source) { + parts.push(`source=${m.source}`); + } + parts.push(`target=${m.target}`); + return parts.join(','); +} + +/** + * Convenience wrapper used by the dashboard UI: load + post + return. + * Rust associates the parsed config with `workspaceId` so subsequent + * lifecycle commands (`container_up`, etc.) can pick it up. + */ +export async function loadAndSubmitDevContainerConfig( + fileHost: FileHost, + workspaceId: string, + workspacePath: string, + env: Record = {}, +): Promise { + const result = await loadDevContainerConfig(fileHost, workspacePath, env); + if (!result) { + return undefined; + } + await bridge.submit_parsed_devcontainer(workspaceId, result.parsed); + return result; +} + diff --git a/frontend/src/lib/bridge.ts b/frontend/src/lib/bridge.ts index db2cad884..3c1ba004d 100644 --- a/frontend/src/lib/bridge.ts +++ b/frontend/src/lib/bridge.ts @@ -16,7 +16,16 @@ export interface WorkspaceEntry { lastOpenedAt?: string; } -export type ContainerState = 'absent' | 'created' | 'running' | 'stopped' | 'error'; +export type ContainerState = + | 'absent' + | 'pulling' + | 'creating' + | 'created' + | 'running' + | 'stopped' + | 'exited' + | 'unknown' + | 'error'; export interface ContainerStatus { workspaceId: string; @@ -40,6 +49,18 @@ export interface LifecycleLogEvent { ts: number; } +/** A container as known to the runtime, irrespective of any workspace + * link the dashboard maintains. */ +export interface ContainerEntry { + containerId: string; + state: 'created' | 'running' | 'stopped' | 'exited' | 'unknown'; + imageRef?: string; + /** Host-side bind-mount sources reported by the runtime, used to + * link a container back to a known repo without relying on a + * matching name. */ + hostMounts: string[]; +} + /** * Strongly typed wrappers around `invoke`. Kept as an object so tests can * monkey-patch individual methods, and so the FileHost adapter in @@ -55,14 +76,25 @@ export const bridge = { // --- runtime ------------------------------------------------------------ list_runtimes: () => invoke('list_runtimes'), select_runtime: (id: RuntimeInfo['id']) => invoke('select_runtime', { id }), + list_containers: () => invoke('list_containers'), + container_start_by_id: (containerId: string) => + invoke('container_start_by_id', { containerId }), + container_stop_by_id: (containerId: string) => + invoke('container_stop_by_id', { containerId }), + container_remove_by_id: (containerId: string, force = true) => + invoke('container_remove_by_id', { containerId, force }), - // --- container lifecycle ------------------------------------------------ + // --- container lifecycle (workspace-scoped) ---------------------------- container_status: (workspaceId: string) => invoke('container_status', { workspaceId }), container_up: (workspaceId: string) => invoke('container_up', { workspaceId }), container_stop: (workspaceId: string) => invoke('container_stop', { workspaceId }), container_rebuild: (workspaceId: string) => invoke('container_rebuild', { workspaceId }), container_remove: (workspaceId: string) => invoke('container_remove', { workspaceId }), + // --- parsed devcontainer.json ------------------------------------------ + submit_parsed_devcontainer: (workspaceId: string, parsed: unknown) => + invoke('submit_parsed_devcontainer', { workspaceId, parsed }), + // --- FileHost bridge ---------------------------------------------------- fs_is_file: (path: string) => invoke('fs_is_file', { path }), fs_read_file: (path: string) => invoke('fs_read_file', { path }), diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fbc7a6426..783336bd8 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,61 +1,774 @@ /*--------------------------------------------------------------------------------------------- * Devcontainers.app — dashboard entry point. * - * This is a deliberately minimal scaffold; the MVP UI (dashboard cards, - * workspace detail, terminal panel, log streaming) is step 8 of the - * conversion roadmap. + * Two-section dashboard, Podman-Desktop-style: a list of tracked repos + * (workspace folders) and a list of containers as known to the selected + * runtime. A repo is just dashboard bookkeeping; it can have a linked + * container (one per repo, by design) but doesn't require one. + * + * Repo actions: Up, Rebuild, Stop, Forget + * Container actions: Start, Stop, Remove + * + * "Forget" only removes the dashboard entry — the container, if any, is + * left alone. Container Remove force-deletes the container and refreshes + * the list. Logs are buffered per-entry so they survive sidebar + * selection changes and accumulate until the entry is removed. *--------------------------------------------------------------------------------------------*/ -import { bridge } from './lib/bridge'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; + +import { + bridge, + type ContainerEntry, + type ContainerStatus, + type LifecycleLogEvent, + type RuntimeInfo, + type WorkspaceEntry, +} from './lib/bridge'; + +// Type-only reference to the engine module so the editor and (where it +// runs) tsc validate our usage. The actual JS comes from the deno-built +// bundle that lives in `frontend/public/devcontainer-engine.js` and is +// loaded at runtime. +type EngineModule = typeof import('./devcontainer-engine'); +let enginePromise: Promise | undefined; +function engine(): Promise { + if (!enginePromise) { + enginePromise = (async () => { + const res = await fetch('/devcontainer-engine.js'); + if (!res.ok) { + throw new Error(`Failed to fetch engine bundle: ${res.status} ${res.statusText}`); + } + const code = await res.text(); + const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' })); + try { + return (await import(/* @vite-ignore */ url)) as EngineModule; + } finally { + URL.revokeObjectURL(url); + } + })(); + } + return enginePromise; +} + +/** Tagged identity for a sidebar entry. */ +type Selection = + | { kind: 'repo'; id: string } + | { kind: 'container'; id: string }; + +interface LogLine { + stream: 'stdout' | 'stderr' | 'system'; + line: string; + ts: number; +} + +interface AppState { + workspaces: WorkspaceEntry[]; + runtimes: RuntimeInfo[]; + containers: ContainerEntry[]; + /** Last known status per workspaceId (from lifecycle events). */ + statuses: Record; + selected?: Selection; + /** Logs keyed by `repo:` or `container:`. */ + logs: Map; +} + +const MAX_LOG_LINES = 5000; + +const state: AppState = { + workspaces: [], + runtimes: [], + containers: [], + statuses: {}, + selected: undefined, + logs: new Map(), +}; + +// ============================================================================= +// Bootstrap +// ============================================================================= async function bootstrap(): Promise { const root = document.getElementById('app'); if (!root) { return; } - root.textContent = 'Loading workspaces…'; + root.textContent = 'Loading…'; try { - const [workspaces, runtimes] = await Promise.all([ + [state.workspaces, state.runtimes, state.containers] = await Promise.all([ bridge.list_workspaces(), bridge.list_runtimes(), + bridge.list_containers().catch(() => []), ]); - render(root, workspaces, runtimes); } catch (err) { - root.textContent = `Failed to load: ${(err as Error).message}`; + root.textContent = `Failed to load: ${errMsg(err)}`; + return; } + + render(root); + subscribe(); + startContainerPolling(); } -function render(root: HTMLElement, workspaces: Awaited>, runtimes: Awaited>): void { +function subscribe(): void { + void bridge.onLifecycleLog((e) => { + const key = logKeyForWorkspace(e.workspaceId); + appendLog(key, { stream: e.stream, line: e.line, ts: e.ts }); + if (selectionMatchesLogKey(key)) { + appendLogToPane({ stream: e.stream, line: e.line, ts: e.ts }); + } + }); + void bridge.onStatusChange((s) => { + state.statuses[s.workspaceId] = s; + updateRepoStatusUi(s.workspaceId); + // A status change usually means a container appeared/disappeared. + void refreshContainers(); + }); +} + +/** Poll the runtime's container list every few seconds so external + * changes (other tools, a `container kill`, etc) get reflected. */ +function startContainerPolling(): void { + setInterval(() => { + void refreshContainers(); + }, 4000); +} + +async function refreshContainers(): Promise { + try { + state.containers = await bridge.list_containers(); + } catch { + // Backend unavailable; keep the previous list. Avoid spamming + // errors on a transient runtime hiccup. + return; + } + renderSidebarOnly(); + if (state.selected?.kind === 'container') { + const cid = state.selected.id; + if (!state.containers.some((c) => c.containerId === cid)) { + state.selected = undefined; + renderDetailOnly(); + } else { + updateContainerDetail(); + } + } +} + +// ============================================================================= +// Logging +// ============================================================================= + +function logKeyForWorkspace(workspaceId: string): string { + return `repo:${workspaceId}`; +} + +function logKeyForContainer(containerId: string): string { + return `container:${containerId}`; +} + +function logKeyForSelection(sel: Selection): string { + return sel.kind === 'repo' ? logKeyForWorkspace(sel.id) : logKeyForContainer(sel.id); +} + +function selectionMatchesLogKey(key: string): boolean { + return state.selected != null && logKeyForSelection(state.selected) === key; +} + +function appendLog(key: string, entry: LogLine): void { + let buf = state.logs.get(key); + if (!buf) { + buf = []; + state.logs.set(key, buf); + } + buf.push(entry); + if (buf.length > MAX_LOG_LINES) { + buf.splice(0, buf.length - MAX_LOG_LINES); + } +} + +/** Synthesize a log entry from the frontend (no Tauri event involved). */ +function logLocal(key: string, stream: LogLine['stream'], line: string): void { + const entry: LogLine = { stream, line, ts: Date.now() }; + appendLog(key, entry); + if (state.selected && logKeyForSelection(state.selected) === key) { + appendLogToPane(entry); + } +} + +// ============================================================================= +// Rendering +// ============================================================================= + +function render(root: HTMLElement): void { root.replaceChildren(); + const header = document.createElement('header'); + header.className = 'app-header'; const heading = document.createElement('h1'); heading.textContent = 'Devcontainers.app'; - root.appendChild(heading); + header.appendChild(heading); - const runtimeList = document.createElement('ul'); - runtimeList.className = 'runtimes'; - for (const r of runtimes) { + const openBtn = document.createElement('button'); + openBtn.textContent = 'Open Folder…'; + openBtn.addEventListener('click', onOpenFolder); + header.appendChild(openBtn); + root.appendChild(header); + + root.appendChild(renderRuntimes()); + + const layout = document.createElement('section'); + layout.className = 'layout'; + const sidebar = renderSidebar(); + sidebar.id = 'sidebar'; + layout.appendChild(sidebar); + const detail = renderDetail(); + detail.id = 'detail'; + layout.appendChild(detail); + root.appendChild(layout); +} + +function renderSidebarOnly(): void { + const old = document.getElementById('sidebar'); + if (!old || !old.parentElement) return; + const next = renderSidebar(); + next.id = 'sidebar'; + old.parentElement.replaceChild(next, old); +} + +function renderDetailOnly(): void { + const old = document.getElementById('detail'); + if (!old || !old.parentElement) return; + const next = renderDetail(); + next.id = 'detail'; + old.parentElement.replaceChild(next, old); +} + +function renderRuntimes(): HTMLElement { + const ul = document.createElement('ul'); + ul.className = 'runtimes'; + for (const r of state.runtimes) { const li = document.createElement('li'); li.textContent = `${r.id}: ${r.available ? (r.version ?? 'available') : (r.reason ?? 'unavailable')}`; - runtimeList.appendChild(li); + ul.appendChild(li); + } + return ul; +} + +function renderSidebar(): HTMLElement { + const aside = document.createElement('aside'); + aside.className = 'sidebar'; + + // --- Repos --------------------------------------------------------- + const reposHeader = document.createElement('h3'); + reposHeader.className = 'sidebar-section'; + reposHeader.textContent = 'Repos'; + aside.appendChild(reposHeader); + + if (state.workspaces.length === 0) { + const empty = document.createElement('p'); + empty.className = 'sidebar-empty'; + empty.textContent = 'No repos. Click "Open Folder…" to add one.'; + aside.appendChild(empty); + } else { + const ul = document.createElement('ul'); + ul.className = 'workspaces'; + for (const w of state.workspaces) { + ul.appendChild(renderRepoItem(w)); + } + aside.appendChild(ul); } - root.appendChild(runtimeList); - if (workspaces.length === 0) { + // --- Containers ---------------------------------------------------- + // Two ways a container can be linked to a repo: + // 1. The lifecycle orchestrator recorded its container id in the + // slot for that repo (most common; fast path). + // 2. The container has a bind-mount whose source matches a known + // workspace path. This catches containers created in a previous + // app run and survives slot resets after a hook failure. + const linkedCids = new Set(); + for (const s of Object.values(state.statuses)) { + if (s?.containerId) linkedCids.add(s.containerId); + } + const repoPaths = new Set(state.workspaces.map((w) => w.path)); + for (const c of state.containers) { + if (c.hostMounts.some((p) => repoPaths.has(p))) { + linkedCids.add(c.containerId); + } + } + const systemContainers = state.containers.filter(isSystemContainer); + const userContainers = state.containers.filter((c) => !isSystemContainer(c)); + + const cHeader = document.createElement('h3'); + cHeader.className = 'sidebar-section'; + cHeader.textContent = `Containers (${userContainers.length})`; + aside.appendChild(cHeader); + + if (userContainers.length === 0) { const empty = document.createElement('p'); - empty.textContent = 'No workspaces yet. Use File → Open Folder… to add one.'; - root.appendChild(empty); + empty.className = 'sidebar-empty'; + empty.textContent = 'No containers.'; + aside.appendChild(empty); + } else { + const ul = document.createElement('ul'); + ul.className = 'workspaces'; + for (const c of userContainers) { + ul.appendChild(renderContainerItem(c, linkedCids.has(c.containerId))); + } + aside.appendChild(ul); + } + + // --- System (BuildKit etc.) --------------------------------------- + if (systemContainers.length > 0) { + const sHeader = document.createElement('h3'); + sHeader.className = 'sidebar-section'; + sHeader.textContent = 'System'; + aside.appendChild(sHeader); + const ul = document.createElement('ul'); + ul.className = 'workspaces'; + for (const c of systemContainers) { + ul.appendChild(renderSystemContainerItem(c)); + } + aside.appendChild(ul); + } + + return aside; +} + +/** Containers managed by the runtime itself (not user dev containers). + * Apple's `container` CLI runs a long-lived `buildkit` helper in the + * same VM and lists it alongside everything else; without this filter + * it would clutter the Containers section and tempt the user into + * Stop/Remove actions that break image builds. */ +function isSystemContainer(c: ContainerEntry): boolean { + if (c.containerId === 'buildkit') return true; + const img = c.imageRef ?? ''; + return img.includes('container-builder-shim'); +} + +function renderSystemContainerItem(c: ContainerEntry): HTMLElement { + const li = document.createElement('li'); + li.dataset.kind = 'system'; + li.dataset.id = c.containerId; + li.classList.add('system-entry'); + const title = document.createElement('div'); + title.className = 'ws-title'; + title.textContent = c.containerId; + const meta = document.createElement('div'); + meta.className = 'ws-path'; + meta.textContent = c.imageRef ?? ''; + const status = document.createElement('div'); + status.className = `ws-status state-${c.state}`; + status.textContent = `${c.state} · runtime helper`; + li.append(title, meta, status); + return li; +} + +function renderRepoItem(w: WorkspaceEntry): HTMLElement { + const li = document.createElement('li'); + li.dataset.kind = 'repo'; + li.dataset.id = w.id; + if (state.selected?.kind === 'repo' && state.selected.id === w.id) { + li.classList.add('selected'); + } + const title = document.createElement('div'); + title.className = 'ws-title'; + title.textContent = w.displayName; + const path = document.createElement('div'); + path.className = 'ws-path'; + path.textContent = w.path; + const status = document.createElement('div'); + status.className = 'ws-status'; + status.textContent = formatRepoStatus(w.id); + li.append(title, path, status); + li.addEventListener('click', () => selectRepo(w.id)); + return li; +} + +function renderContainerItem(c: ContainerEntry, linked: boolean): HTMLElement { + const li = document.createElement('li'); + li.dataset.kind = 'container'; + li.dataset.id = c.containerId; + if (state.selected?.kind === 'container' && state.selected.id === c.containerId) { + li.classList.add('selected'); + } + const title = document.createElement('div'); + title.className = 'ws-title'; + title.textContent = c.containerId; + const meta = document.createElement('div'); + meta.className = 'ws-path'; + meta.textContent = c.imageRef ?? ''; + const status = document.createElement('div'); + status.className = `ws-status state-${c.state}`; + const linkedRepo = linked ? findLinkedRepo(c) : undefined; + const linkText = linkedRepo ? ` · ${linkedRepo.displayName}` : linked ? ' · linked' : ''; + status.textContent = `${c.state}${linkText}`; + li.append(title, meta, status); + li.addEventListener('click', () => selectContainer(c.containerId)); + return li; +} + +/** Find the repo a container is bound to via either an orchestrator + * slot or a matching bind-mount source. Returns the first match in + * workspace order. */ +function findLinkedRepo(c: ContainerEntry): WorkspaceEntry | undefined { + for (const w of state.workspaces) { + if (state.statuses[w.id]?.containerId === c.containerId) return w; + } + for (const w of state.workspaces) { + if (c.hostMounts.includes(w.path)) return w; + } + return undefined; +} + +function formatRepoStatus(workspaceId: string): string { + const s = state.statuses[workspaceId]; + if (s) { + const parts: string[] = [s.state]; + if (s.containerId) parts.push(s.containerId); + return parts.join(' · '); + } + // No orchestrator slot yet (e.g. fresh app start). Look for a + // running container whose bind-mount source matches this repo's + // path and surface its state so the dashboard isn't misleadingly + // blank. + const ws = state.workspaces.find((w) => w.id === workspaceId); + if (ws) { + const match = state.containers.find((c) => c.hostMounts.includes(ws.path)); + if (match) return `${match.state} · ${match.containerId}`; + } + return 'no container'; +} + +function renderDetail(): HTMLElement { + const main = document.createElement('section'); + main.className = 'detail'; + + if (!state.selected) { + const empty = document.createElement('p'); + empty.textContent = 'Select a repo or container on the left.'; + main.appendChild(empty); + return main; + } + + if (state.selected.kind === 'repo') { + const ws = state.workspaces.find((w) => w.id === state.selected!.id); + if (!ws) { + state.selected = undefined; + const empty = document.createElement('p'); + empty.textContent = 'Repo no longer present.'; + main.appendChild(empty); + return main; + } + main.appendChild(renderRepoDetailHeader(ws)); + main.appendChild(renderStatusLine(ws.id)); + } else { + const c = state.containers.find((x) => x.containerId === state.selected!.id); + if (!c) { + state.selected = undefined; + const empty = document.createElement('p'); + empty.textContent = 'Container no longer present.'; + main.appendChild(empty); + return main; + } + main.appendChild(renderContainerDetailHeader(c)); + main.appendChild(renderContainerStatusLine(c)); + } + + main.appendChild(renderLogPane(logKeyForSelection(state.selected))); + return main; +} + +function renderRepoDetailHeader(ws: WorkspaceEntry): HTMLElement { + const header = document.createElement('div'); + header.className = 'detail-header'; + const title = document.createElement('h2'); + title.textContent = ws.displayName; + header.appendChild(title); + + const actions = document.createElement('div'); + actions.className = 'actions'; + for (const [label, fn] of [ + ['Up', () => repoAction('up', ws.id)], + ['Stop', () => repoAction('stop', ws.id)], + ['Rebuild', () => repoAction('rebuild', ws.id)], + ['Forget', () => forgetRepo(ws.id)], + ] as const) { + const btn = document.createElement('button'); + btn.textContent = label; + btn.addEventListener('click', () => void fn()); + actions.appendChild(btn); + } + header.appendChild(actions); + return header; +} + +function renderContainerDetailHeader(c: ContainerEntry): HTMLElement { + const header = document.createElement('div'); + header.className = 'detail-header'; + const title = document.createElement('h2'); + title.textContent = c.containerId; + header.appendChild(title); + + const actions = document.createElement('div'); + actions.className = 'actions'; + const isRunning = c.state === 'running'; + for (const [label, fn, disabled] of [ + ['Start', () => containerAction('start', c.containerId), isRunning], + ['Stop', () => containerAction('stop', c.containerId), !isRunning], + ['Remove', () => containerAction('remove', c.containerId), false], + ] as const) { + const btn = document.createElement('button'); + btn.textContent = label; + btn.disabled = disabled; + btn.addEventListener('click', () => void fn()); + actions.appendChild(btn); + } + header.appendChild(actions); + return header; +} + +function renderStatusLine(workspaceId: string): HTMLElement { + const s = state.statuses[workspaceId]; + const p = document.createElement('p'); + p.className = 'status-line'; + p.id = 'status-line'; + p.textContent = s ? formatStatusLine(s) : 'no container'; + return p; +} + +function renderContainerStatusLine(c: ContainerEntry): HTMLElement { + const p = document.createElement('p'); + p.className = 'status-line'; + p.id = 'status-line'; + p.textContent = `${c.state}${c.imageRef ? ` — ${c.imageRef}` : ''}`; + return p; +} + +function formatStatusLine(s: ContainerStatus): string { + const parts: string[] = [s.state]; + if (s.containerId) parts.push(s.containerId); + if (s.error) parts.push(`error: ${s.error}`); + return parts.join(' \u2014 '); +} + +function renderLogPane(logKey: string): HTMLElement { + const pane = document.createElement('pre'); + pane.className = 'log-pane'; + pane.id = 'log-pane'; + const buf = state.logs.get(logKey); + if (buf && buf.length > 0) { + const frag = document.createDocumentFragment(); + for (const entry of buf) { + frag.appendChild(makeLogLineNode(entry)); + } + pane.appendChild(frag); + } + queueMicrotask(() => { + pane.scrollTop = pane.scrollHeight; + }); + return pane; +} + +function appendLogToPane(entry: LogLine): void { + const pane = document.getElementById('log-pane'); + if (!pane) return; + const nearBottom = pane.scrollHeight - pane.scrollTop - pane.clientHeight < 40; + pane.appendChild(makeLogLineNode(entry)); + while (pane.childElementCount > MAX_LOG_LINES) { + pane.removeChild(pane.firstChild!); + } + if (nearBottom) { + pane.scrollTop = pane.scrollHeight; + } +} + +function makeLogLineNode(entry: LogLine): HTMLElement { + const row = document.createElement('div'); + row.className = `log-line log-${entry.stream}`; + row.textContent = entry.line; + return row; +} + +// Surgical updates that don't tear down the log pane --------------------- + +function updateRepoStatusUi(workspaceId: string): void { + const sidebarStatus = document.querySelector( + `li[data-kind="repo"][data-id="${cssEscape(workspaceId)}"] .ws-status`, + ); + if (sidebarStatus) { + sidebarStatus.textContent = formatRepoStatus(workspaceId); + } + if (state.selected?.kind === 'repo' && state.selected.id === workspaceId) { + const line = document.getElementById('status-line'); + const s = state.statuses[workspaceId]; + if (line) line.textContent = s ? formatStatusLine(s) : 'no container'; + } +} + +function updateContainerDetail(): void { + if (state.selected?.kind !== 'container') return; + const cid = state.selected.id; + const c = state.containers.find((x) => x.containerId === cid); + if (!c) return; + const line = document.getElementById('status-line'); + if (line) line.textContent = `${c.state}${c.imageRef ? ` — ${c.imageRef}` : ''}`; +} + +function cssEscape(s: string): string { + return s.replace(/["\\]/g, '\\$&'); +} + +// ============================================================================= +// Selection +// ============================================================================= + +function selectRepo(id: string): void { + state.selected = { kind: 'repo', id }; + renderSidebarOnly(); + renderDetailOnly(); + void refreshRepoStatus(id); + void loadConfigForWorkspace(id); +} + +function selectContainer(id: string): void { + state.selected = { kind: 'container', id }; + renderSidebarOnly(); + renderDetailOnly(); +} + +async function refreshRepoStatus(id: string): Promise { + try { + state.statuses[id] = await bridge.container_status(id); + } catch (err) { + state.statuses[id] = { + workspaceId: id, + state: 'error', + error: errMsg(err), + }; + } + updateRepoStatusUi(id); +} + +async function loadConfigForWorkspace(id: string): Promise { + const ws = state.workspaces.find((w) => w.id === id); + if (!ws) return; + const key = logKeyForWorkspace(id); + try { + const eng = await engine(); + const result = await eng.loadAndSubmitDevContainerConfig( + eng.tauriFileHost(), + id, + ws.path, + ); + if (!result) { + logLocal(key, 'system', `No .devcontainer/devcontainer.json found in ${ws.path}`); + } + } catch (err) { + logLocal(key, 'stderr', `Failed to load devcontainer.json: ${errMsg(err)}`); + } +} + +// ============================================================================= +// Actions +// ============================================================================= + +async function onOpenFolder(): Promise { + const picked = await openDialog({ directory: true, multiple: false }); + if (typeof picked !== 'string') return; + try { + const ws = await bridge.add_workspace(picked); + state.workspaces.push(ws); + state.selected = { kind: 'repo', id: ws.id }; + } catch (err) { + const msg = errMsg(err); + logLocal(`repo:${picked}`, 'stderr', `Failed to add workspace: ${msg}`); + state.statuses[picked] = { workspaceId: picked, state: 'error', error: msg }; + } + renderSidebarOnly(); + renderDetailOnly(); + if (state.selected?.kind === 'repo') { + void loadConfigForWorkspace(state.selected.id); + } +} + +type RepoAction = 'up' | 'stop' | 'rebuild'; + +async function repoAction(kind: RepoAction, workspaceId: string): Promise { + const key = logKeyForWorkspace(workspaceId); + try { + const fn = { + up: bridge.container_up, + stop: bridge.container_stop, + rebuild: bridge.container_rebuild, + }[kind]; + const status = await fn(workspaceId); + state.statuses[workspaceId] = status; + } catch (err) { + const msg = errMsg(err); + state.statuses[workspaceId] = { + workspaceId, + state: 'error', + error: msg, + }; + logLocal(key, 'stderr', `${kind} failed: ${msg}`); + } + updateRepoStatusUi(workspaceId); + void refreshContainers(); +} + +async function forgetRepo(workspaceId: string): Promise { + const key = logKeyForWorkspace(workspaceId); + try { + await bridge.remove_workspace(workspaceId); + } catch (err) { + logLocal(key, 'stderr', `Forget failed: ${errMsg(err)}`); return; } + state.workspaces = state.workspaces.filter((w) => w.id !== workspaceId); + delete state.statuses[workspaceId]; + state.logs.delete(key); + if (state.selected?.kind === 'repo' && state.selected.id === workspaceId) { + state.selected = undefined; + } + renderSidebarOnly(); + renderDetailOnly(); +} - const ul = document.createElement('ul'); - ul.className = 'workspaces'; - for (const w of workspaces) { - const li = document.createElement('li'); - li.textContent = `${w.displayName} — ${w.path}`; - ul.appendChild(li); +type ContainerActionKind = 'start' | 'stop' | 'remove'; + +async function containerAction(kind: ContainerActionKind, containerId: string): Promise { + const key = logKeyForContainer(containerId); + logLocal(key, 'system', `${kind} ${containerId}`); + try { + if (kind === 'start') await bridge.container_start_by_id(containerId); + else if (kind === 'stop') await bridge.container_stop_by_id(containerId); + else await bridge.container_remove_by_id(containerId, true); + } catch (err) { + logLocal(key, 'stderr', `${kind} failed: ${errMsg(err)}`); + } + await refreshContainers(); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Coerce any thrown value into a useful message string. Tauri rejects + * with the plain `String` returned from `Result<_, String>`, so + * `(err as Error).message` is `undefined` in that path. */ +function errMsg(err: unknown): string { + if (err == null) return 'unknown error'; + if (typeof err === 'string') return err; + if (err instanceof Error) return err.message; + if (typeof err === 'object' && 'message' in err) { + const m = (err as { message: unknown }).message; + if (typeof m === 'string') return m; + } + try { + return JSON.stringify(err); + } catch { + return String(err); } - root.appendChild(ul); } void bootstrap(); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0ce87c19c..656b7e51a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -5,15 +5,234 @@ body { margin: 0; - padding: 1.5rem; + padding: 0; +} + +#app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + border-bottom: 1px solid #8884; +} + +.app-header h1 { + margin: 0; + font-size: 1.1rem; } ul.runtimes { + margin: 0; + padding: 0.4rem 1.25rem; + list-style: none; + color: #888; + font-size: 0.8rem; + border-bottom: 1px solid #8884; + display: flex; + gap: 1rem; +} + +ul.runtimes li::before { + content: "● "; + color: #4a4; +} + +.layout { + display: grid; + grid-template-columns: minmax(220px, 280px) 1fr; + flex: 1; + min-height: 0; +} + +.sidebar { + border-right: 1px solid #8884; + overflow-y: auto; + padding: 0.5rem; +} + +.sidebar p { + margin: 1rem 0.5rem; color: #888; - font-size: 0.9rem; +} + +.sidebar-section { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #888; + margin: 0.75rem 0.5rem 0.25rem; +} + +.sidebar-empty { + margin: 0.25rem 0.5rem 0.5rem !important; + font-size: 0.8rem; + font-style: italic; +} + +.state-running { + color: #4a4; +} + +.state-stopped, +.state-exited { + color: #c80; +} + +.state-created { + color: #888; +} + +.state-unknown { + color: #888; +} +ul.workspaces li.system-entry { + cursor: default; + opacity: 0.7; +} + +ul.workspaces li.system-entry:hover { + background: transparent; +} +ul.workspaces { + list-style: none; + margin: 0; + padding: 0; } ul.workspaces li { - padding: 0.5rem 0; - border-bottom: 1px solid #ccc4; + padding: 0.5rem 0.75rem; + border-radius: 6px; + cursor: pointer; +} + +ul.workspaces li:hover { + background: #8881; +} + +ul.workspaces li.selected { + background: #2563eb22; +} + +.ws-title { + font-weight: 600; +} + +.ws-path { + font-size: 0.75rem; + color: #888; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + +.ws-status { + font-size: 0.75rem; + color: #4a4; + margin-top: 0.15rem; +} + +.detail { + display: flex; + flex-direction: column; + min-height: 0; + padding: 1rem; +} + +.detail-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.detail-header h2 { + margin: 0; + font-size: 1.05rem; +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.actions button { + padding: 0.35rem 0.75rem; + border-radius: 6px; + border: 1px solid #8886; + background: transparent; + cursor: pointer; + font: inherit; +} + +.actions button:hover { + background: #8881; +} + +.app-header button { + padding: 0.35rem 0.85rem; + border-radius: 6px; + border: 1px solid #2563eb; + background: #2563eb; + color: white; + cursor: pointer; + font: inherit; +} + +.status-line { + margin: 0 0 0.75rem 0; + font-size: 0.85rem; + color: #888; +} + +.log-pane { + flex: 1; + min-height: 240px; + margin: 0; + padding: 0.5rem 0.75rem; + overflow: auto; + overflow-wrap: anywhere; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.45; + /* System palette: respects light/dark via color-scheme on :root. */ + background: Canvas; + color: CanvasText; + border: 1px solid #8884; + border-radius: 6px; + user-select: text; + -webkit-user-select: text; + } + + .log-line { + white-space: pre-wrap; + } + + .log-stderr { + color: #c62828; + } + + @media (prefers-color-scheme: dark) { + .log-stderr { + color: #ff6b6b; + } + } + + .log-system { + color: #666; + font-style: italic; + } + + @media (prefers-color-scheme: dark) { + .log-system { + color: #aaa; + } +} + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 654c6ddec..6f803972c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,5 +14,12 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, sourcemap: true, + rollupOptions: { + // `/devcontainer-engine.js` is the deno-built spec slice bundle + // served from `frontend/public/`. We mark it external so Rollup + // keeps the runtime URL intact and does not try to resolve and + // inline a module that depends on Node built-ins. + external: ['/devcontainer-engine.js'], + }, }, }); diff --git a/frontend/yarn.lock b/frontend/yarn.lock new file mode 100644 index 000000000..7a1a9da32 --- /dev/null +++ b/frontend/yarn.lock @@ -0,0 +1,417 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" + integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== + +"@esbuild/android-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" + integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== + +"@esbuild/android-arm@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" + integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== + +"@esbuild/android-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" + integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== + +"@esbuild/darwin-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" + integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== + +"@esbuild/darwin-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" + integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== + +"@esbuild/freebsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" + integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== + +"@esbuild/freebsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" + integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== + +"@esbuild/linux-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" + integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== + +"@esbuild/linux-arm@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" + integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== + +"@esbuild/linux-ia32@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" + integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== + +"@esbuild/linux-loong64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" + integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== + +"@esbuild/linux-mips64el@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" + integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== + +"@esbuild/linux-ppc64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" + integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== + +"@esbuild/linux-riscv64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" + integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== + +"@esbuild/linux-s390x@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" + integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== + +"@esbuild/linux-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" + integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== + +"@esbuild/netbsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" + integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== + +"@esbuild/netbsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" + integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== + +"@esbuild/openbsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" + integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== + +"@esbuild/openbsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" + integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== + +"@esbuild/openharmony-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" + integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== + +"@esbuild/sunos-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" + integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== + +"@esbuild/win32-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" + integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== + +"@esbuild/win32-ia32@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" + integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== + +"@esbuild/win32-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" + integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== + +"@rollup/rollup-android-arm-eabi@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz#a19c645c375158cd5c50a344106f0fa18eb821c4" + integrity sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw== + +"@rollup/rollup-android-arm64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz#1af19aa9d3ad6d00df2681f59cfcb8bf7499576b" + integrity sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg== + +"@rollup/rollup-darwin-arm64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz#3b8463e03ba2a393453fea70e7d907379c27b649" + integrity sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA== + +"@rollup/rollup-darwin-x64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz#28da23d69fe117f5f0ff330a8549e51bd09f1b6a" + integrity sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g== + +"@rollup/rollup-freebsd-arm64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz#94bacac3190f621de1355922b599f3817786044c" + integrity sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw== + +"@rollup/rollup-freebsd-x64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz#8a0094f533b9fda160b5c90ad9e0c78fca341788" + integrity sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ== + +"@rollup/rollup-linux-arm-gnueabihf@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz#3b7e901a555c7245c87f7440979bee0a1ec882bb" + integrity sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg== + +"@rollup/rollup-linux-arm-musleabihf@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz#ee9a09b72e8ad764cfd6188b32ff1de528ff7ebe" + integrity sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw== + +"@rollup/rollup-linux-arm64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz#ba483f4aca9be141171d086dbd01ada6ab03b58d" + integrity sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg== + +"@rollup/rollup-linux-arm64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz#17b595b790e6df68e91c5d02526fc832a985ce4f" + integrity sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA== + +"@rollup/rollup-linux-loong64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz#551718714075a2bfb36a2813c466e3a0e9d56abf" + integrity sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A== + +"@rollup/rollup-linux-loong64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz#ba156ed1243447a3d710972001d5dcfe3827ff3d" + integrity sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q== + +"@rollup/rollup-linux-ppc64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz#6a957a709b86ac62ef68e597ac03dbd4336782b1" + integrity sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw== + +"@rollup/rollup-linux-ppc64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz#ca4176b4ad53f3edee3b4bfa6f9ef48ff38f167b" + integrity sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ== + +"@rollup/rollup-linux-riscv64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz#4e6b08f72ebeafdb41f3ec433bd228ba8573473b" + integrity sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A== + +"@rollup/rollup-linux-riscv64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz#a0b8b8580c7680c8086cb3226527e5472253b895" + integrity sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ== + +"@rollup/rollup-linux-s390x-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz#79fe15b92ce0bae2b609cf26dd158cd3e2b73634" + integrity sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA== + +"@rollup/rollup-linux-x64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz#6aa8302fa45fd3cbbc510ccd223c9c37bf67e53f" + integrity sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ== + +"@rollup/rollup-linux-x64-musl@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz#0c1a5e9799f80c47a66f2c3a5f1a280f38356047" + integrity sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw== + +"@rollup/rollup-openbsd-x64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz#5f07c863e74fd428794f1dc5749f321b661d1f17" + integrity sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg== + +"@rollup/rollup-openharmony-arm64@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz#8e0d71324be0f423428b12b25a2eb8ea8e0a7833" + integrity sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q== + +"@rollup/rollup-win32-arm64-msvc@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz#a553fdf90a785ace6d7501eed6241c468b088999" + integrity sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ== + +"@rollup/rollup-win32-ia32-msvc@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz#0fb04f0a88027fbfd323e25a446debce4773868c" + integrity sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg== + +"@rollup/rollup-win32-x64-gnu@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz#aaa9e36dbdc0f0e397e5966dcce1b4285354ede2" + integrity sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA== + +"@rollup/rollup-win32-x64-msvc@4.60.2": + version "4.60.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz#3418dcf1388f2abd6b0ccc08fe1ef205ae76d696" + integrity sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA== + +"@tauri-apps/api@^2.0.0", "@tauri-apps/api@^2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.10.1.tgz#57c1bae6114ec33d977eb2b50dfefc25fa84fc93" + integrity sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw== + +"@tauri-apps/plugin-dialog@^2.0.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz#b510ecd42d9900725eaf51f42ec98523c40d29b4" + integrity sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw== + dependencies: + "@tauri-apps/api" "^2.10.1" + +"@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@xterm/addon-fit@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@xterm/addon-fit/-/addon-fit-0.10.0.tgz#bebf87fadd74e3af30fdcdeef47030e2592c6f55" + integrity sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ== + +"@xterm/xterm@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396" + integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== + +esbuild@^0.27.0: + version "0.27.7" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" + integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.7" + "@esbuild/android-arm" "0.27.7" + "@esbuild/android-arm64" "0.27.7" + "@esbuild/android-x64" "0.27.7" + "@esbuild/darwin-arm64" "0.27.7" + "@esbuild/darwin-x64" "0.27.7" + "@esbuild/freebsd-arm64" "0.27.7" + "@esbuild/freebsd-x64" "0.27.7" + "@esbuild/linux-arm" "0.27.7" + "@esbuild/linux-arm64" "0.27.7" + "@esbuild/linux-ia32" "0.27.7" + "@esbuild/linux-loong64" "0.27.7" + "@esbuild/linux-mips64el" "0.27.7" + "@esbuild/linux-ppc64" "0.27.7" + "@esbuild/linux-riscv64" "0.27.7" + "@esbuild/linux-s390x" "0.27.7" + "@esbuild/linux-x64" "0.27.7" + "@esbuild/netbsd-arm64" "0.27.7" + "@esbuild/netbsd-x64" "0.27.7" + "@esbuild/openbsd-arm64" "0.27.7" + "@esbuild/openbsd-x64" "0.27.7" + "@esbuild/openharmony-arm64" "0.27.7" + "@esbuild/sunos-x64" "0.27.7" + "@esbuild/win32-arm64" "0.27.7" + "@esbuild/win32-ia32" "0.27.7" + "@esbuild/win32-x64" "0.27.7" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.3, picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + +postcss@^8.5.6: + version "8.5.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" + integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +rollup@^4.43.0: + version "4.60.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.2.tgz#ac23fe4bd530304cef9fa61e639d7098b6762cf4" + integrity sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.60.2" + "@rollup/rollup-android-arm64" "4.60.2" + "@rollup/rollup-darwin-arm64" "4.60.2" + "@rollup/rollup-darwin-x64" "4.60.2" + "@rollup/rollup-freebsd-arm64" "4.60.2" + "@rollup/rollup-freebsd-x64" "4.60.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.60.2" + "@rollup/rollup-linux-arm-musleabihf" "4.60.2" + "@rollup/rollup-linux-arm64-gnu" "4.60.2" + "@rollup/rollup-linux-arm64-musl" "4.60.2" + "@rollup/rollup-linux-loong64-gnu" "4.60.2" + "@rollup/rollup-linux-loong64-musl" "4.60.2" + "@rollup/rollup-linux-ppc64-gnu" "4.60.2" + "@rollup/rollup-linux-ppc64-musl" "4.60.2" + "@rollup/rollup-linux-riscv64-gnu" "4.60.2" + "@rollup/rollup-linux-riscv64-musl" "4.60.2" + "@rollup/rollup-linux-s390x-gnu" "4.60.2" + "@rollup/rollup-linux-x64-gnu" "4.60.2" + "@rollup/rollup-linux-x64-musl" "4.60.2" + "@rollup/rollup-openbsd-x64" "4.60.2" + "@rollup/rollup-openharmony-arm64" "4.60.2" + "@rollup/rollup-win32-arm64-msvc" "4.60.2" + "@rollup/rollup-win32-ia32-msvc" "4.60.2" + "@rollup/rollup-win32-x64-gnu" "4.60.2" + "@rollup/rollup-win32-x64-msvc" "4.60.2" + fsevents "~2.3.2" + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +tinyglobby@^0.2.15: + version "0.2.16" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +typescript@^5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +vite@^7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.2.tgz#cb041794d4c1395e28baea98198fd6e8f4b96b5c" + integrity sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg== + dependencies: + esbuild "^0.27.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" diff --git a/scripts/build-engine.ts b/scripts/build-engine.ts index 6eb708214..80bd854e0 100644 --- a/scripts/build-engine.ts +++ b/scripts/build-engine.ts @@ -11,11 +11,18 @@ import * as esbuild from 'npm:esbuild@^0.27.3'; import { polyfillNode } from 'npm:esbuild-plugin-polyfill-node@^0.3.0'; import { dirname, join } from 'node:path'; +import { copyFile, mkdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const entry = join(repoRoot, 'frontend', 'src', 'devcontainer-engine', 'index.ts'); const outFile = join(repoRoot, 'dist', 'devcontainer-engine.js'); +// The Tauri WebView loads the engine bundle as a side-loaded ES module at +// `/devcontainer-engine.js`. Vite serves anything in `frontend/public/` +// verbatim, so writing a copy there is the cleanest way to expose the +// bundle to the running app without a separate static-asset pipeline. +const publicCopy = join(repoRoot, 'frontend', 'public', 'devcontainer-engine.js'); +const publicCopyMap = `${publicCopy}.map`; await esbuild.build({ entryPoints: [entry], @@ -52,3 +59,10 @@ await esbuild.build({ await esbuild.stop(); console.log(`✔ wrote ${outFile}`); + +// Mirror the bundle (and its source map) into `frontend/public/` so the +// WebView can load it from the Vite dev server / built dist. +await mkdir(dirname(publicCopy), { recursive: true }); +await copyFile(outFile, publicCopy); +await copyFile(`${outFile}.map`, publicCopyMap); +console.log(`✔ copied bundle to ${publicCopy}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c34ae321d..da1d9abb2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -574,6 +574,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "thiserror 1.0.69", "tokio", "tracing", @@ -2097,6 +2098,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2780,6 +2782,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3501,6 +3527,65 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.10.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 13433bd96..3d868f100 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 000000000..b6bb7aa44 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability for the Devcontainers app main window. Grants the WebView access to the in-tree Tauri commands and the dialog plugin used by the 'Open Folder…' action.", + "windows": ["main"], + "permissions": [ + "core:default", + "core:event:default", + "core:window:default", + "core:webview:default", + "core:app:default", + "dialog:default" + ] +} diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 43da9ef60..116d3af79 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"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. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"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}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 9e26dfeeb..b1f4f67c5 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capability for the Devcontainers app main window. Grants the WebView access to the in-tree Tauri commands and the dialog plugin used by the 'Open Folder…' action.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:webview:default","core:app:default","dialog:default"]}} \ 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 260dbe05a..a1f466509 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -2143,6 +2143,72 @@ "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "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\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "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\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." } ] }, diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json index 260dbe05a..a1f466509 100644 --- a/src-tauri/gen/schemas/linux-schema.json +++ b/src-tauri/gen/schemas/linux-schema.json @@ -2143,6 +2143,72 @@ "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "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\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "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\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." } ] }, diff --git a/src-tauri/gen/schemas/macOS-schema.json b/src-tauri/gen/schemas/macOS-schema.json index 260dbe05a..a1f466509 100644 --- a/src-tauri/gen/schemas/macOS-schema.json +++ b/src-tauri/gen/schemas/macOS-schema.json @@ -2143,6 +2143,72 @@ "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "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\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "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\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." } ] }, diff --git a/src-tauri/src/commands/lifecycle.rs b/src-tauri/src/commands/lifecycle.rs index dad1b73f9..1809a6498 100644 --- a/src-tauri/src/commands/lifecycle.rs +++ b/src-tauri/src/commands/lifecycle.rs @@ -1,17 +1,19 @@ //! Container lifecycle commands. Each one resolves the workspace, picks -//! the selected runtime, and delegates to it. The actual lifecycle -//! orchestration (devcontainer.json parsing, hook execution, log -//! streaming) lands in step 7 of the conversion roadmap; for now these -//! commands are wired through to the runtime trait so the IPC surface is -//! complete and testable. +//! the selected runtime, and delegates to the +//! [`crate::devcontainer::lifecycle::LifecycleOrchestrator`]. + +use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; -use crate::container::{ContainerState, RuntimeRegistry}; +use crate::container::RuntimeRegistry; +use crate::devcontainer::lifecycle::{LifecycleOrchestrator, LifecycleStatus}; +use crate::devcontainer::translate::ParsedDevContainer; use crate::host::HostState; #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ContainerStatusDto { pub workspace_id: String, pub state: String, @@ -20,91 +22,111 @@ pub struct ContainerStatusDto { pub error: Option, } -fn workspace_must_exist(state: &HostState, workspace_id: &str) -> Result<(), String> { - let inner = state.inner.read(); - if inner.workspaces.iter().any(|w| w.id == workspace_id) { - Ok(()) - } else { - Err(format!("unknown workspace: {workspace_id}")) +impl From for ContainerStatusDto { + fn from(s: LifecycleStatus) -> Self { + Self { + workspace_id: s.workspace_id, + state: s.state.to_string(), + container_id: s.container_id, + image_ref: s.image_ref, + error: s.error, + } } } -fn pending(workspace_id: &str, msg: &str) -> ContainerStatusDto { - ContainerStatusDto { - workspace_id: workspace_id.to_string(), - state: state_to_string(ContainerState::Unknown), - container_id: None, - image_ref: None, - error: Some(msg.to_string()), - } +fn resolve_workspace(state: &HostState, workspace_id: &str) -> Result { + let inner = state.inner.read(); + inner + .workspaces + .iter() + .find(|w| w.id == workspace_id) + .map(|w| w.path.clone()) + .ok_or_else(|| format!("unknown workspace: {workspace_id}")) } -fn state_to_string(s: ContainerState) -> String { - match s { - ContainerState::Created => "created", - ContainerState::Running => "running", - ContainerState::Stopped => "stopped", - ContainerState::Exited => "exited", - ContainerState::Unknown => "unknown", - } - .into() +#[tauri::command] +pub async fn submit_parsed_devcontainer( + state: State<'_, HostState>, + orchestrator: State<'_, LifecycleOrchestrator>, + workspace_id: String, + parsed: ParsedDevContainer, +) -> Result<(), String> { + resolve_workspace(&state, &workspace_id)?; + orchestrator.set_parsed_config(&workspace_id, parsed); + Ok(()) } #[tauri::command] pub async fn container_status( state: State<'_, HostState>, _registry: State<'_, RuntimeRegistry>, + orchestrator: State<'_, LifecycleOrchestrator>, workspace_id: String, ) -> Result { - workspace_must_exist(&state, &workspace_id)?; - Ok(pending( - &workspace_id, - "lifecycle orchestrator lands in step 7 of the roadmap", - )) + resolve_workspace(&state, &workspace_id)?; + Ok(orchestrator.snapshot(&workspace_id).into()) } #[tauri::command] pub async fn container_up( + app: AppHandle, state: State<'_, HostState>, - _registry: State<'_, RuntimeRegistry>, + registry: State<'_, RuntimeRegistry>, + orchestrator: State<'_, LifecycleOrchestrator>, workspace_id: String, ) -> Result { - workspace_must_exist(&state, &workspace_id)?; - Ok(pending(&workspace_id, "container_up not yet implemented")) + let path = resolve_workspace(&state, &workspace_id)?; + orchestrator + .up(&app, ®istry, &workspace_id, &path) + .await + .map(ContainerStatusDto::from) + .map_err(|e| e.to_string()) } #[tauri::command] pub async fn container_stop( + app: AppHandle, state: State<'_, HostState>, - _registry: State<'_, RuntimeRegistry>, + registry: State<'_, RuntimeRegistry>, + orchestrator: State<'_, LifecycleOrchestrator>, workspace_id: String, ) -> Result { - workspace_must_exist(&state, &workspace_id)?; - Ok(pending(&workspace_id, "container_stop not yet implemented")) + resolve_workspace(&state, &workspace_id)?; + orchestrator + .stop(&app, ®istry, &workspace_id) + .await + .map(ContainerStatusDto::from) + .map_err(|e| e.to_string()) } #[tauri::command] pub async fn container_rebuild( + app: AppHandle, state: State<'_, HostState>, - _registry: State<'_, RuntimeRegistry>, + registry: State<'_, RuntimeRegistry>, + orchestrator: State<'_, LifecycleOrchestrator>, workspace_id: String, ) -> Result { - workspace_must_exist(&state, &workspace_id)?; - Ok(pending( - &workspace_id, - "container_rebuild not yet implemented", - )) + let path = resolve_workspace(&state, &workspace_id)?; + orchestrator + .rebuild(&app, ®istry, &workspace_id, &path) + .await + .map(ContainerStatusDto::from) + .map_err(|e| e.to_string()) } #[tauri::command] pub async fn container_remove( + app: AppHandle, state: State<'_, HostState>, - _registry: State<'_, RuntimeRegistry>, + registry: State<'_, RuntimeRegistry>, + orchestrator: State<'_, LifecycleOrchestrator>, workspace_id: String, ) -> Result { - workspace_must_exist(&state, &workspace_id)?; - Ok(pending( - &workspace_id, - "container_remove not yet implemented", - )) + resolve_workspace(&state, &workspace_id)?; + orchestrator + .remove(&app, ®istry, &workspace_id) + .await + .map(ContainerStatusDto::from) + .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/runtime.rs b/src-tauri/src/commands/runtime.rs index 9c56a8cf3..96b2c0bc5 100644 --- a/src-tauri/src/commands/runtime.rs +++ b/src-tauri/src/commands/runtime.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use tauri::State; -use crate::container::{RuntimeId, RuntimeRegistry}; +use crate::container::{ContainerState, RuntimeId, RuntimeRegistry}; #[derive(Debug, Serialize, Deserialize)] pub struct RuntimeInfoDto { @@ -37,3 +37,74 @@ pub async fn select_runtime( ) -> Result<(), String> { registry.select(id).map_err(|e| e.to_string()) } + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContainerEntry { + pub container_id: String, + pub state: String, + pub image_ref: Option, + /// Host-side bind-mount sources, used by the dashboard to link a + /// container back to a known repo without relying on container + /// names. + pub host_mounts: Vec, +} + +/// List all containers known to the selected runtime, regardless of +/// whether the dashboard knows about them. The dashboard joins this with +/// its workspace list to render linked-container badges and a separate +/// "unlinked containers" section. +#[tauri::command] +pub async fn list_containers( + registry: State<'_, RuntimeRegistry>, +) -> Result, String> { + let runtime = registry.selected(); + let statuses = runtime.list().await.map_err(|e| e.to_string())?; + Ok(statuses + .into_iter() + .map(|s| ContainerEntry { + container_id: s.container_id, + state: state_str(s.state).to_string(), + image_ref: s.image_ref, + host_mounts: s.host_mounts, + }) + .collect()) +} + +fn state_str(s: ContainerState) -> &'static str { + match s { + ContainerState::Created => "created", + ContainerState::Running => "running", + ContainerState::Stopped => "stopped", + ContainerState::Exited => "exited", + ContainerState::Unknown => "unknown", + } +} + +#[tauri::command] +pub async fn container_start_by_id( + registry: State<'_, RuntimeRegistry>, + container_id: String, +) -> Result<(), String> { + let runtime = registry.selected(); + runtime.start(&container_id).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn container_stop_by_id( + registry: State<'_, RuntimeRegistry>, + container_id: String, +) -> Result<(), String> { + let runtime = registry.selected(); + runtime.stop(&container_id).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn container_remove_by_id( + registry: State<'_, RuntimeRegistry>, + container_id: String, + force: bool, +) -> Result<(), String> { + let runtime = registry.selected(); + runtime.remove(&container_id, force).await.map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index 00f588ec1..fd2a2dc03 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -7,6 +7,7 @@ use uuid::Uuid; use crate::host::{HostState, Workspace}; #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct WorkspaceDto { pub id: String, pub path: String, diff --git a/src-tauri/src/container/apple_containers.rs b/src-tauri/src/container/apple_containers.rs deleted file mode 100644 index 2684e0fb4..000000000 --- a/src-tauri/src/container/apple_containers.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Apple Containers backend. -//! -//! v1 drives the `container` CLI bundled with macOS 26+. Where a Swift/C -//! programmatic API exists in a future macOS release, the implementation -//! should switch to it; until then we shell out to `container …` and parse -//! the JSON output. This module is a stub — real `pull/create/start/exec/ -//! logs/stop/remove` lands in step 5 of the conversion roadmap. - -use async_trait::async_trait; - -use super::traits::{ - ContainerRuntime, ContainerRuntimeError, ContainerSpec, ContainerStatus, ExecOptions, - ExecResult, ImageRef, RuntimeAvailability, RuntimeId, -}; - -#[derive(Debug, Default)] -pub struct AppleContainersRuntime {} - -impl AppleContainersRuntime { - pub fn new() -> Self { - Self::default() - } -} - -#[async_trait] -impl ContainerRuntime for AppleContainersRuntime { - fn id(&self) -> RuntimeId { - RuntimeId::AppleContainers - } - - async fn probe(&self) -> Result { - // On non-mac hosts the runtime is definitively unavailable. - #[cfg(not(target_os = "macos"))] - { - return Ok(RuntimeAvailability { - available: false, - version: None, - reason: Some("Apple Containers requires macOS 26 or later".into()), - }); - } - #[cfg(target_os = "macos")] - { - // Real implementation: `container --version`. Stubbed for now. - Ok(RuntimeAvailability { - available: false, - version: None, - reason: Some("not yet implemented (step 5 of the conversion roadmap)".into()), - }) - } - } - - async fn pull(&self, _image: &ImageRef) -> Result<(), ContainerRuntimeError> { - Err(unimplemented_err("pull")) - } - async fn create(&self, _spec: &ContainerSpec) -> Result { - Err(unimplemented_err("create")) - } - async fn start(&self, _container_id: &str) -> Result<(), ContainerRuntimeError> { - Err(unimplemented_err("start")) - } - async fn stop(&self, _container_id: &str) -> Result<(), ContainerRuntimeError> { - Err(unimplemented_err("stop")) - } - async fn remove(&self, _container_id: &str, _force: bool) -> Result<(), ContainerRuntimeError> { - Err(unimplemented_err("remove")) - } - async fn inspect(&self, _container_id: &str) -> Result { - Err(unimplemented_err("inspect")) - } - async fn list(&self) -> Result, ContainerRuntimeError> { - Err(unimplemented_err("list")) - } - async fn exec( - &self, - _container_id: &str, - _options: &ExecOptions, - ) -> Result { - Err(unimplemented_err("exec")) - } -} - -fn unimplemented_err(op: &'static str) -> ContainerRuntimeError { - ContainerRuntimeError::Unsupported(format!("apple-containers `{op}` not implemented yet")) -} diff --git a/src-tauri/src/container/apple_containers/cli.rs b/src-tauri/src/container/apple_containers/cli.rs new file mode 100644 index 000000000..0ff6ba49f --- /dev/null +++ b/src-tauri/src/container/apple_containers/cli.rs @@ -0,0 +1,264 @@ +//! `container` CLI surface used by [`super::AppleContainersRuntime`]. +//! +//! Building the argv vectors as pure functions keeps the wire format +//! testable without a real `container` binary on `$PATH`. + +use tokio::process::Command; + +use crate::container::traits::{ + BuildSpec, ContainerRuntimeError, ContainerSpec, ContainerStatus, ExecOptions, ImageRef, + LogOptions, +}; + +use super::{mount_flag, InspectShape}; + +#[derive(Debug, Clone)] +pub struct ContainerCli { + binary: String, +} + +impl ContainerCli { + pub fn new(binary: impl Into) -> Self { + Self { + binary: binary.into(), + } + } + + pub fn binary(&self) -> &str { + &self.binary + } + + pub fn command(&self) -> Command { + Command::new(&self.binary) + } +} + +impl Default for ContainerCli { + fn default() -> Self { + Self::new("container") + } +} + +/// Format an [`ImageRef`] back into the canonical `[registry/]repo[:tag][@digest]` +/// string accepted by `container image pull`. +pub(crate) fn image_ref_to_string(image: &ImageRef) -> String { + let mut s = String::new(); + if let Some(reg) = &image.registry { + s.push_str(reg); + s.push('/'); + } + s.push_str(&image.repository); + if let Some(tag) = &image.tag { + s.push(':'); + s.push_str(tag); + } + if let Some(d) = &image.digest { + s.push('@'); + s.push_str(d); + } + s +} + +pub(crate) fn pull_args(image: &ImageRef) -> Vec { + vec![ + "image".to_string(), + "pull".to_string(), + image_ref_to_string(image), + ] +} + +/// Argv for `container build --tag --file [--build-arg k=v]... [--target T] `. +/// Apple's `container` CLI mirrors Docker/Podman's flags here. Build +/// args are sorted so the argv is deterministic for tests. +pub(crate) fn build_args(spec: &BuildSpec) -> Vec { + let mut a = vec![ + "build".to_string(), + "--tag".to_string(), + image_ref_to_string(&spec.tag), + "--file".to_string(), + spec.dockerfile.display().to_string(), + ]; + let mut args: Vec<(&String, &String)> = spec.build_args.iter().collect(); + args.sort_by(|x, y| x.0.cmp(y.0)); + for (k, v) in args { + a.push("--build-arg".to_string()); + a.push(format!("{k}={v}")); + } + if let Some(target) = &spec.target { + a.push("--target".to_string()); + a.push(target.clone()); + } + a.push(spec.context_dir.display().to_string()); + a +} + +pub(crate) fn create_args(spec: &ContainerSpec) -> Vec { + let mut a = vec![ + "create".to_string(), + "--name".to_string(), + spec.name.clone(), + ]; + + for (k, v) in &spec.env { + a.push("--env".to_string()); + a.push(format!("{k}={v}")); + } + // Sort so argv is deterministic regardless of HashMap iteration order. + sort_kv_block(&mut a, "--env"); + + for m in &spec.mounts { + a.push("--mount".to_string()); + a.push(mount_flag(m)); + } + + for p in &spec.ports { + a.push("--publish".to_string()); + let proto = match p.protocol { + crate::container::PortProtocol::Tcp => "tcp", + crate::container::PortProtocol::Udp => "udp", + }; + a.push(format!("{}:{}/{proto}", p.host_port, p.container_port)); + } + + if let Some(workdir) = &spec.workdir { + a.push("--workdir".to_string()); + a.push(workdir.display().to_string()); + } + if let Some(user) = &spec.user { + a.push("--user".to_string()); + a.push(user.clone()); + } + if spec.privileged { + a.push("--privileged".to_string()); + } + + a.push(image_ref_to_string(&spec.image)); + if let Some(cmd) = &spec.command { + for arg in cmd { + a.push(arg.clone()); + } + } + a +} + +/// Sort consecutive `flag value` pairs in-place so the argv we emit is +/// deterministic for tests. Pairs not matching `flag` are left alone. +fn sort_kv_block(args: &mut Vec, flag: &str) { + let mut i = 0; + while i < args.len() { + if args[i] == flag { + let start = i; + let mut end = i; + while end + 1 < args.len() && args[end] == flag { + end += 2; + } + // `end` now points at one past the last value in the block. + let mut pairs: Vec<(String, String)> = args[start..end] + .chunks(2) + .map(|c| (c[0].clone(), c[1].clone())) + .collect(); + pairs.sort_by(|a, b| a.1.cmp(&b.1)); + let mut flat: Vec = Vec::with_capacity(end - start); + for (k, v) in pairs { + flat.push(k); + flat.push(v); + } + args.splice(start..end, flat); + i = end; + } else { + i += 1; + } + } +} + +pub(crate) fn remove_args(container_id: &str, force: bool) -> Vec { + let mut a = vec!["delete".to_string()]; + if force { + a.push("--force".to_string()); + } + a.push(container_id.to_string()); + a +} + +pub(crate) fn exec_args(container_id: &str, options: &ExecOptions) -> Vec { + let mut a = vec!["exec".to_string()]; + if options.tty { + a.push("--tty".to_string()); + } + if let Some(workdir) = &options.workdir { + a.push("--workdir".to_string()); + a.push(workdir.display().to_string()); + } + if let Some(user) = &options.user { + a.push("--user".to_string()); + a.push(user.clone()); + } + let mut envs: Vec<(&String, &String)> = options.env.iter().collect(); + envs.sort_by(|x, y| x.0.cmp(y.0)); + for (k, v) in envs { + a.push("--env".to_string()); + a.push(format!("{k}={v}")); + } + a.push(container_id.to_string()); + // NOTE: Apple's `container exec` takes process arguments positionally + // straight after the container id; it does NOT accept a `--` + // separator (it would be interpreted as the executable name and + // fail with `failed to find target executable --`). + for arg in &options.command { + a.push(arg.clone()); + } + a +} + +pub(crate) fn logs_args(container_id: &str, options: &LogOptions) -> Vec { + let mut a = vec!["logs".to_string()]; + if options.follow { + a.push("--follow".to_string()); + } + if let Some(t) = options.tail { + a.push("--tail".to_string()); + a.push(t.to_string()); + } + a.push(container_id.to_string()); + a +} + +pub(crate) fn parse_inspect( + stdout: &str, + container_id: &str, +) -> Result { + let trimmed = stdout.trim(); + if trimmed.is_empty() { + return Err(ContainerRuntimeError::Backend( + "`container inspect` returned empty output".into(), + )); + } + // Apple's CLI sometimes wraps a single object in a one-element array. + if let Ok(arr) = serde_json::from_str::>(trimmed) { + if let Some(first) = arr.into_iter().next() { + return Ok(first.into_status()); + } + // Apple's `container inspect ` exits 0 with `[]` instead + // of erroring. Surface this as a recognisable "not found" so the + // orchestrator's adoption / remove-on-rebuild paths can branch + // on it via `is_not_found`. + return Err(ContainerRuntimeError::Backend(format!( + "container with ID {container_id} not found" + ))); + } + let one: InspectShape = serde_json::from_str(trimmed).map_err(|e| { + ContainerRuntimeError::Backend(format!("could not parse `container inspect`: {e}")) + })?; + Ok(one.into_status()) +} + +pub(crate) fn parse_list(stdout: &str) -> Result, ContainerRuntimeError> { + let trimmed = stdout.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + let arr: Vec = serde_json::from_str(trimmed).map_err(|e| { + ContainerRuntimeError::Backend(format!("could not parse `container list`: {e}")) + })?; + Ok(arr.into_iter().map(InspectShape::into_status).collect()) +} diff --git a/src-tauri/src/container/apple_containers/mod.rs b/src-tauri/src/container/apple_containers/mod.rs new file mode 100644 index 000000000..10a3687a8 --- /dev/null +++ b/src-tauri/src/container/apple_containers/mod.rs @@ -0,0 +1,532 @@ +//! Apple Containers backend. +//! +//! v1 drives the `container` CLI bundled with macOS 26+. Where a Swift/C +//! programmatic API exists in a future macOS release, the implementation +//! should switch to it; until then we shell out to `container …` and parse +//! the JSON output. +//! +//! The exact subcommand surface of `container` is captured in +//! [`cli::ContainerCli`] so it can be unit-tested without the binary +//! installed and replaced with a fake in `apple-containers-live` integration +//! tests. + +use std::ffi::OsStr; +use std::process::Stdio; + +use async_trait::async_trait; +use serde::Deserialize; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +use super::traits::{ + BuildSpec, ContainerRuntime, ContainerRuntimeError, ContainerSpec, ContainerState, + ContainerStatus, ExecOptions, ExecResult, ImageRef, LogChunk, LogOptions, LogStream, + LogStreamKind, MountKind, RuntimeAvailability, RuntimeId, +}; + +mod cli; +#[cfg(test)] +mod tests; + +pub use cli::ContainerCli; + +/// Apple Containers backend. The binary name (`container` by default) is +/// configurable via [`AppleContainersRuntime::with_binary`] so the live +/// integration tests can point at a fake on `$PATH`. +#[derive(Debug, Clone)] +pub struct AppleContainersRuntime { + cli: ContainerCli, +} + +impl Default for AppleContainersRuntime { + fn default() -> Self { + Self::new() + } +} + +impl AppleContainersRuntime { + pub fn new() -> Self { + Self { + cli: ContainerCli::default(), + } + } + + pub fn with_binary(binary: impl Into) -> Self { + Self { + cli: ContainerCli::new(binary), + } + } +} + +#[async_trait] +impl ContainerRuntime for AppleContainersRuntime { + fn id(&self) -> RuntimeId { + RuntimeId::AppleContainers + } + + async fn probe(&self) -> Result { + // On non-mac hosts the runtime is definitively unavailable. + #[cfg(not(target_os = "macos"))] + { + return Ok(RuntimeAvailability { + available: false, + version: None, + reason: Some("Apple Containers requires macOS 26 or later".into()), + }); + } + #[cfg(target_os = "macos")] + { + match run_capturing(&self.cli, ["--version"]).await { + Ok((stdout, _)) => Ok(RuntimeAvailability { + available: true, + version: Some(stdout.trim().to_string()).filter(|s| !s.is_empty()), + reason: None, + }), + Err(e) => Ok(RuntimeAvailability { + available: false, + version: None, + reason: Some(format!("`{}` not runnable: {e}", self.cli.binary())), + }), + } + } + } + + async fn pull(&self, image: &ImageRef) -> Result<(), ContainerRuntimeError> { + let args = cli::pull_args(image); + run_capturing(&self.cli, args.iter().map(String::as_str)).await?; + Ok(()) + } + + /// Build an image via `container build`. Streams stdout/stderr lines + /// to `log_sink` as they arrive so the dashboard can show progress in + /// real time, then returns the resulting [`ImageRef`] (== `spec.tag`). + async fn build( + &self, + spec: &BuildSpec, + log_sink: Option>, + ) -> Result { + let args = cli::build_args(spec); + let mut cmd = self.cli.command(); + for a in &args { + cmd.arg(a); + } + cmd.stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + info!( + binary = self.cli.binary(), + argv = ?args, + "running container build" + ); + + let mut child = cmd + .spawn() + .map_err(|e| ContainerRuntimeError::Backend(format!("spawn `container build`: {e}")))?; + let stdout = child.stdout.take().ok_or_else(|| { + ContainerRuntimeError::Backend("no stdout from `container build`".into()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + ContainerRuntimeError::Backend("no stderr from `container build`".into()) + })?; + + // Pump output to the optional sink. Even when no sink is wired up + // we still need to drain the pipes so the child does not block on + // a full buffer; we collect stderr into a String so we can include + // it in the error on failure. + let stderr_buf: std::sync::Arc> = + std::sync::Arc::new(parking_lot::Mutex::new(String::new())); + + let stdout_sink = log_sink.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Some(tx) = &stdout_sink { + if tx + .send(LogChunk { + stream: LogStreamKind::Stdout, + line, + }) + .await + .is_err() + { + break; + } + } + } + }); + + let stderr_sink = log_sink.clone(); + let stderr_buf2 = stderr_buf.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + { + let mut buf = stderr_buf2.lock(); + buf.push_str(&line); + buf.push('\n'); + } + if let Some(tx) = &stderr_sink { + if tx + .send(LogChunk { + stream: LogStreamKind::Stderr, + line, + }) + .await + .is_err() + { + break; + } + } + } + }); + + let status = child + .wait() + .await + .map_err(|e| ContainerRuntimeError::Backend(format!("wait `container build`: {e}")))?; + + if !status.success() { + let stderr = stderr_buf.lock().clone(); + warn!( + binary = self.cli.binary(), + argv = ?args, + %status, + stderr = %stderr.trim(), + "container build exited non-zero", + ); + return Err(ContainerRuntimeError::Backend(format!( + "`{} {}` exited with {status}: {}", + self.cli.binary(), + args.join(" "), + stderr.trim() + ))); + } + Ok(spec.tag.clone()) + } + + async fn create(&self, spec: &ContainerSpec) -> Result { + let args = cli::create_args(spec); + let (stdout, _) = run_capturing(&self.cli, args.iter().map(String::as_str)).await?; + let id = stdout + .trim() + .lines() + .last() + .unwrap_or("") + .trim() + .to_string(); + if id.is_empty() { + return Err(ContainerRuntimeError::Backend( + "`container create` returned no container id".into(), + )); + } + Ok(id) + } + + async fn start(&self, container_id: &str) -> Result<(), ContainerRuntimeError> { + ensure_container_id(container_id)?; + run_capturing(&self.cli, ["start", container_id]).await?; + // Apple's `container start` returns as soon as the start request is + // accepted, but the runtime may still be transitioning the + // container into "running" state when we immediately try to + // `exec` the postCreateCommand. Poll inspect briefly so we + // don't race the lifecycle hook into "cannot exec: container is + // not running". + for attempt in 0u32..20 { + match self.inspect(container_id).await { + Ok(status) if matches!(status.state, ContainerState::Running) => return Ok(()), + Ok(_) | Err(_) => { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + debug!(container = container_id, attempt, "waiting for running state"); + } + } + } + warn!( + container = container_id, + "container did not reach Running state within timeout; continuing anyway" + ); + Ok(()) + } + + async fn stop(&self, container_id: &str) -> Result<(), ContainerRuntimeError> { + ensure_container_id(container_id)?; + run_capturing(&self.cli, ["stop", container_id]).await?; + Ok(()) + } + + async fn remove(&self, container_id: &str, force: bool) -> Result<(), ContainerRuntimeError> { + ensure_container_id(container_id)?; + let args = cli::remove_args(container_id, force); + run_capturing(&self.cli, args.iter().map(String::as_str)).await?; + Ok(()) + } + + async fn inspect(&self, container_id: &str) -> Result { + ensure_container_id(container_id)?; + let (stdout, _) = + run_capturing(&self.cli, ["inspect", container_id]).await?; + cli::parse_inspect(&stdout, container_id) + } + + async fn list(&self) -> Result, ContainerRuntimeError> { + let (stdout, _) = run_capturing(&self.cli, ["list", "--all", "--format", "json"]).await?; + cli::parse_list(&stdout) + } + + async fn exec( + &self, + container_id: &str, + options: &ExecOptions, + ) -> Result { + let args = cli::exec_args(container_id, options); + info!( + binary = self.cli.binary(), + argv = ?args, + "running container exec" + ); + let mut cmd = self.cli.command(); + for a in &args { + cmd.arg(a); + } + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let output = cmd + .output() + .await + .map_err(|e| ContainerRuntimeError::Backend(format!("spawn `container exec`: {e}")))?; + let exit_code = output.status.code().unwrap_or(-1); + if exit_code != 0 { + warn!( + binary = self.cli.binary(), + argv = ?args, + exit_code, + stderr = %String::from_utf8_lossy(&output.stderr).trim(), + "container exec exited non-zero", + ); + } + Ok(ExecResult { + exit_code, + stdout: output.stdout, + stderr: output.stderr, + }) + } + + async fn logs( + &self, + container_id: &str, + options: &LogOptions, + ) -> Result { + let args = cli::logs_args(container_id, options); + let mut cmd = self.cli.command(); + for a in &args { + cmd.arg(a); + } + cmd.stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd + .spawn() + .map_err(|e| ContainerRuntimeError::Backend(format!("spawn `container logs`: {e}")))?; + let stdout = child.stdout.take().ok_or_else(|| { + ContainerRuntimeError::Backend("no stdout from `container logs`".into()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + ContainerRuntimeError::Backend("no stderr from `container logs`".into()) + })?; + + // Bound the channel so a slow consumer cannot grow memory without + // limit. When the consumer drops the receiver, `send` will fail and + // the spawned task will exit, which drops `child` and (with + // `kill_on_drop`) terminates the `container logs` process. + let (tx, rx) = mpsc::channel::(256); + + spawn_line_pump(stdout, LogStreamKind::Stdout, tx.clone()); + spawn_line_pump(stderr, LogStreamKind::Stderr, tx.clone()); + tokio::spawn(async move { + let _ = child.wait().await; + drop(tx); + }); + + Ok(rx) + } +} + +fn spawn_line_pump(reader: R, kind: LogStreamKind, tx: mpsc::Sender) +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, +{ + tokio::spawn(async move { + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if tx.send(LogChunk { stream: kind, line }).await.is_err() { + break; + } + } + }); +} + +async fn run_capturing( + cli: &ContainerCli, + args: I, +) -> Result<(String, String), ContainerRuntimeError> +where + I: IntoIterator, + S: AsRef, +{ + let mut cmd = cli.command(); + let mut argv: Vec = Vec::new(); + for a in args { + let s = a.as_ref().to_string_lossy().into_owned(); + cmd.arg(a); + argv.push(s); + } + debug!(binary = %cli.binary(), argv = ?argv, "running container CLI"); + let output = cmd.output().await.map_err(|e| { + ContainerRuntimeError::Backend(format!("failed to run `{}`: {e}", cli.binary())) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + warn!( + binary = %cli.binary(), + argv = ?argv, + status = %output.status, + stderr = %stderr, + "container CLI exited non-zero" + ); + return Err(ContainerRuntimeError::Backend(format!( + "`{} {}` exited with {}: {}", + cli.binary(), + argv.join(" "), + output.status, + stderr, + ))); + } + Ok(( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + )) +} + +/// A `serde`-compatible shape for the subset of `container inspect` / +/// `container list` output the orchestrator cares about. +/// +/// Apple's CLI nests most identity fields under a `configuration` object +/// (id, image, etc.) while runtime state lives at the top level +/// (`status`, `startedDate`, `networks`). Older builds and our own +/// fixtures sometimes put `id`/`image` at the top level too, so both +/// shapes are accepted; the nested values win when present. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InspectShape { + pub id: Option, + pub name: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub state: Option, + #[serde(default)] + pub image: Option, + #[serde(default)] + pub configuration: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InspectConfiguration { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub image: Option, + /// Bind/volume mounts attached to the container. We only consume the + /// `source` (host-side path) for repo linkage; the rest is ignored. + #[serde(default)] + pub mounts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InspectMount { + #[serde(default)] + pub source: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InspectImage { + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub name: Option, +} + +impl InspectShape { + pub(crate) fn into_status(self) -> ContainerStatus { + let state = match self + .status + .as_deref() + .or(self.state.as_deref()) + .map(|s| s.to_ascii_lowercase()) + { + Some(ref s) if s == "running" => ContainerState::Running, + Some(ref s) if s == "stopped" => ContainerState::Stopped, + Some(ref s) if s == "exited" => ContainerState::Exited, + Some(ref s) if s == "created" => ContainerState::Created, + _ => ContainerState::Unknown, + }; + let (cfg_id, cfg_image, cfg_mounts) = match self.configuration { + Some(c) => (c.id, c.image, c.mounts), + None => (None, None, Vec::new()), + }; + let container_id = cfg_id + .or(self.id) + .or(self.name) + .unwrap_or_default(); + let image_ref = cfg_image + .or(self.image) + .and_then(|i| i.reference.or(i.name)); + let host_mounts = cfg_mounts + .into_iter() + .filter_map(|m| m.source.filter(|s| !s.is_empty())) + .collect(); + ContainerStatus { + container_id, + state, + image_ref, + host_mounts, + } + } +} + +/// Reject calls into the runtime with an empty container id. Apple's +/// `container` CLI surfaces an empty argv as `notFound: container with +/// ID not found`, which is confusing and indistinguishable from a +/// genuinely missing container; bail out earlier with a clear message +/// so the dashboard can ignore the call (and the bug that produced the +/// empty id is easier to spot). +fn ensure_container_id(id: &str) -> Result<(), ContainerRuntimeError> { + if id.trim().is_empty() { + return Err(ContainerRuntimeError::Backend( + "missing container id (empty string passed to runtime)".into(), + )); + } + Ok(()) +} + +/// Format a [`crate::container::MountSpec`] as the `--mount` flag value +/// accepted by `container create`. +pub(crate) fn mount_flag(m: &super::traits::MountSpec) -> String { + let kind = match m.kind { + MountKind::Bind => "bind", + MountKind::Volume => "volume", + }; + let mut s = format!( + "type={},source={},target={}", + kind, + m.source.display(), + m.target.display() + ); + if m.read_only { + s.push_str(",readonly"); + } + s +} diff --git a/src-tauri/src/container/apple_containers/tests.rs b/src-tauri/src/container/apple_containers/tests.rs new file mode 100644 index 000000000..9da5e2dfb --- /dev/null +++ b/src-tauri/src/container/apple_containers/tests.rs @@ -0,0 +1,268 @@ +//! Unit tests for the Apple Containers backend. +//! +//! The argv shaping and JSON parsers are pure functions, so they can be +//! tested without the real `container` binary on `$PATH`. The streaming +//! `logs` path is exercised via a fake shell script that mimics the +//! relevant subcommand surface. + +use std::collections::HashMap; +use std::path::PathBuf; + +use super::cli::{ + create_args, exec_args, image_ref_to_string, logs_args, parse_inspect, parse_list, pull_args, + remove_args, +}; +use crate::container::traits::{ + ContainerSpec, ContainerState, ExecOptions, ImageRef, LogOptions, MountKind, MountSpec, + PortForward, PortProtocol, +}; + +fn ubuntu() -> ImageRef { + ImageRef { + registry: None, + repository: "ubuntu".into(), + tag: Some("24.04".into()), + digest: None, + } +} + +#[test] +fn image_ref_roundtrip() { + assert_eq!(image_ref_to_string(&ubuntu()), "ubuntu:24.04"); + let r = ImageRef { + registry: Some("ghcr.io".into()), + repository: "wiki3-ai/app".into(), + tag: Some("1.0".into()), + digest: Some("sha256:deadbeef".into()), + }; + assert_eq!( + image_ref_to_string(&r), + "ghcr.io/wiki3-ai/app:1.0@sha256:deadbeef" + ); +} + +#[test] +fn pull_args_minimal() { + assert_eq!( + pull_args(&ubuntu()), + vec!["image".to_string(), "pull".into(), "ubuntu:24.04".into()] + ); +} + +#[test] +fn create_args_sorts_env_and_appends_image_last() { + let mut env = HashMap::new(); + env.insert("Z".to_string(), "z".to_string()); + env.insert("A".to_string(), "a".to_string()); + let spec = ContainerSpec { + name: "dev".into(), + image: ubuntu(), + command: Some(vec!["bash".into(), "-l".into()]), + workdir: Some(PathBuf::from("/work")), + env, + mounts: vec![MountSpec { + kind: MountKind::Bind, + source: PathBuf::from("/host/src"), + target: PathBuf::from("/work"), + read_only: false, + }], + ports: vec![PortForward { + host_port: 3000, + container_port: 3000, + protocol: PortProtocol::Tcp, + }], + user: Some("vscode".into()), + privileged: false, + }; + let args = create_args(&spec); + + // First three args are deterministic. + assert_eq!(&args[0..3], &["create", "--name", "dev"]); + + // The image must come immediately before the command. + let img_idx = args.iter().position(|a| a == "ubuntu:24.04").unwrap(); + assert_eq!(args[img_idx + 1], "bash"); + assert_eq!(args[img_idx + 2], "-l"); + assert_eq!(img_idx + 3, args.len()); + + // Env pairs are sorted by value (which mirrors key in this case). + let env_a = args.iter().position(|a| a == "A=a").unwrap(); + let env_z = args.iter().position(|a| a == "Z=z").unwrap(); + assert!(env_a < env_z, "env should be sorted: {args:?}"); + + // Mount flag is the well-known `type=bind,...` shape. + let mount = args.iter().position(|a| a == "--mount").unwrap(); + assert_eq!(args[mount + 1], "type=bind,source=/host/src,target=/work"); + + // Port publish uses host:container/proto. + let publish = args.iter().position(|a| a == "--publish").unwrap(); + assert_eq!(args[publish + 1], "3000:3000/tcp"); +} + +#[test] +fn create_args_marks_readonly_bind() { + let spec = ContainerSpec { + name: "ro".into(), + image: ubuntu(), + command: None, + workdir: None, + env: HashMap::new(), + mounts: vec![MountSpec { + kind: MountKind::Bind, + source: PathBuf::from("/host"), + target: PathBuf::from("/in"), + read_only: true, + }], + ports: vec![], + user: None, + privileged: true, + }; + let args = create_args(&spec); + let mount = args.iter().position(|a| a == "--mount").unwrap(); + assert_eq!( + args[mount + 1], + "type=bind,source=/host,target=/in,readonly" + ); + assert!(args.iter().any(|a| a == "--privileged")); +} + +#[test] +fn remove_args_force_flag() { + assert_eq!(remove_args("abc", false), vec!["delete", "abc"]); + assert_eq!(remove_args("abc", true), vec!["delete", "--force", "abc"]); +} + +#[test] +fn exec_args_orders_options() { + let mut env = HashMap::new(); + env.insert("B".into(), "2".into()); + env.insert("A".into(), "1".into()); + let opts = ExecOptions { + command: vec!["echo".into(), "hi".into()], + workdir: Some(PathBuf::from("/w")), + env, + user: Some("root".into()), + tty: true, + }; + let a = exec_args("cid", &opts); + assert_eq!(a[0], "exec"); + assert!(a.contains(&"--tty".to_string())); + let cid = a.iter().position(|s| s == "cid").unwrap(); + // Apple's `container exec` takes the process argv positionally with + // no `--` separator; the executable name follows the container id. + assert_eq!(a[cid + 1], "echo"); + assert_eq!(a[cid + 2], "hi"); + assert!(!a.iter().any(|s| s == "--")); + // env order: A=1 must precede B=2 + let a1 = a.iter().position(|s| s == "A=1").unwrap(); + let b2 = a.iter().position(|s| s == "B=2").unwrap(); + assert!(a1 < b2); +} + +#[test] +fn logs_args_follow_and_tail() { + let opts = LogOptions { + follow: true, + tail: Some(50), + }; + assert_eq!( + logs_args("cid", &opts), + vec!["logs", "--follow", "--tail", "50", "cid"] + ); + let opts = LogOptions::default(); + assert_eq!(logs_args("cid", &opts), vec!["logs", "cid"]); +} + +#[test] +fn parse_inspect_object_and_array() { + let one = r#"{"id":"abc","status":"running","image":{"reference":"ubuntu:24.04"}}"#; + let s = parse_inspect(one, "abc").unwrap(); + assert_eq!(s.container_id, "abc"); + assert_eq!(s.state, ContainerState::Running); + assert_eq!(s.image_ref.as_deref(), Some("ubuntu:24.04")); + + let many = r#"[{"id":"x","status":"stopped"}]"#; + let s = parse_inspect(many, "x").unwrap(); + assert_eq!(s.container_id, "x"); + assert_eq!(s.state, ContainerState::Stopped); +} + +#[test] +fn parse_inspect_unknown_state() { + let s = parse_inspect(r#"{"id":"y"}"#, "y").unwrap(); + assert_eq!(s.state, ContainerState::Unknown); +} + +#[test] +fn parse_inspect_rejects_empty() { + assert!(parse_inspect("", "anything").is_err()); + // Empty array means "not found" — surfaced with that exact phrase + // so `is_not_found` in the orchestrator recognises it. + let err = parse_inspect("[]", "ghost").unwrap_err(); + let msg = format!("{err}").to_ascii_lowercase(); + assert!(msg.contains("not found"), "got: {msg}"); + assert!(msg.contains("ghost"), "got: {msg}"); +} + +#[test] +fn parse_list_empty_and_populated() { + assert_eq!(parse_list("").unwrap().len(), 0); + assert_eq!(parse_list("[]").unwrap().len(), 0); + let v = parse_list(r#"[{"id":"a","status":"running"},{"id":"b","status":"exited"}]"#).unwrap(); + assert_eq!(v.len(), 2); + assert_eq!(v[0].state, ContainerState::Running); + assert_eq!(v[1].state, ContainerState::Exited); +} + +#[test] +fn parse_list_reads_nested_configuration_id_and_image() { + // Real shape emitted by `container list --all --format json` (Apple + // container CLI 0.x): top-level `status`, identity nested under + // `configuration`. Without nested-id support we'd return an empty + // container_id and any subsequent stop/remove would fail with + // "container with ID not found". + let s = r#"[{ + "status":"running", + "configuration":{ + "id":"buildkit", + "image":{"reference":"ghcr.io/apple/container-builder-shim/builder:0.11.0"} + } + }]"#; + let v = parse_list(s).unwrap(); + assert_eq!(v.len(), 1); + assert_eq!(v[0].container_id, "buildkit"); + assert_eq!(v[0].state, ContainerState::Running); + assert_eq!( + v[0].image_ref.as_deref(), + Some("ghcr.io/apple/container-builder-shim/builder:0.11.0") + ); +} + +#[test] +fn parse_inspect_extracts_host_mount_sources() { + // Trimmed copy of real `container inspect ` output. The + // dashboard joins `host_mounts[*]` against known workspace paths to + // render the repo↔container link without relying on the container + // having the same name as the repo folder. + let s = r#"[{ + "configuration":{ + "id":"JupyterLite-Demo", + "image":{"reference":"devcontainer-take-two:latest"}, + "mounts":[ + {"type":{"virtiofs":{}},"source":"/Users/jim/Wiki3/take-two","options":[],"destination":"/workspaces/take-two"} + ] + }, + "status":"running" + }]"#; + let st = parse_inspect(s, "JupyterLite-Demo").unwrap(); + assert_eq!(st.container_id, "JupyterLite-Demo"); + assert_eq!(st.state, ContainerState::Running); + assert_eq!(st.host_mounts, vec!["/Users/jim/Wiki3/take-two".to_string()]); +} + +#[test] +fn parse_inspect_no_mounts_yields_empty_vec() { + let s = r#"{"id":"x","status":"running"}"#; + let st = parse_inspect(s, "x").unwrap(); + assert!(st.host_mounts.is_empty()); +} diff --git a/src-tauri/src/container/docker.rs b/src-tauri/src/container/docker.rs index cc5445347..62bcecc71 100644 --- a/src-tauri/src/container/docker.rs +++ b/src-tauri/src/container/docker.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use super::traits::{ ContainerRuntime, ContainerRuntimeError, ContainerSpec, ContainerStatus, ExecOptions, - ExecResult, ImageRef, RuntimeAvailability, RuntimeId, + ExecResult, ImageRef, LogOptions, LogStream, RuntimeAvailability, RuntimeId, }; #[derive(Debug, Default)] @@ -55,6 +55,9 @@ impl ContainerRuntime for DockerRuntime { async fn exec(&self, _: &str, _: &ExecOptions) -> Result { Err(unsupported()) } + async fn logs(&self, _: &str, _: &LogOptions) -> Result { + Err(unsupported()) + } } fn unsupported() -> ContainerRuntimeError { diff --git a/src-tauri/src/container/mod.rs b/src-tauri/src/container/mod.rs index 661728e26..59ab66b04 100644 --- a/src-tauri/src/container/mod.rs +++ b/src-tauri/src/container/mod.rs @@ -11,8 +11,9 @@ pub mod podman; pub mod traits; pub use traits::{ - ContainerRuntime, ContainerRuntimeError, ContainerSpec, ContainerState, ContainerStatus, - ExecOptions, ExecResult, ImageRef, MountKind, MountSpec, PortForward, PortProtocol, RuntimeId, + BuildSpec, ContainerRuntime, ContainerRuntimeError, ContainerSpec, ContainerState, + ContainerStatus, ExecOptions, ExecResult, ImageRef, LogChunk, LogOptions, LogStream, + LogStreamKind, MountKind, MountSpec, PortForward, PortProtocol, RuntimeAvailability, RuntimeId, }; use std::collections::HashMap; @@ -68,4 +69,18 @@ impl RuntimeRegistry { .cloned() .expect("selected runtime must be registered") } + + /// Test helper: build a registry with exactly one backend, already + /// selected. Lets the lifecycle orchestrator be exercised end-to-end + /// against a fake `ContainerRuntime` without touching the real + /// Apple/Podman/Docker backends. + #[doc(hidden)] + pub fn with_single(id: RuntimeId, backend: Arc) -> Self { + let mut backends: HashMap> = HashMap::new(); + backends.insert(id, backend); + Self { + backends, + selected: RwLock::new(id), + } + } } diff --git a/src-tauri/src/container/podman.rs b/src-tauri/src/container/podman.rs index 1bd9ba0e9..e43753594 100644 --- a/src-tauri/src/container/podman.rs +++ b/src-tauri/src/container/podman.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use super::traits::{ ContainerRuntime, ContainerRuntimeError, ContainerSpec, ContainerStatus, ExecOptions, - ExecResult, ImageRef, RuntimeAvailability, RuntimeId, + ExecResult, ImageRef, LogOptions, LogStream, RuntimeAvailability, RuntimeId, }; #[derive(Debug, Default)] @@ -55,6 +55,9 @@ impl ContainerRuntime for PodmanRuntime { async fn exec(&self, _: &str, _: &ExecOptions) -> Result { Err(unsupported()) } + async fn logs(&self, _: &str, _: &LogOptions) -> Result { + Err(unsupported()) + } } fn unsupported() -> ContainerRuntimeError { diff --git a/src-tauri/src/container/traits.rs b/src-tauri/src/container/traits.rs index 3c84b25e8..b3d121546 100644 --- a/src-tauri/src/container/traits.rs +++ b/src-tauri/src/container/traits.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use thiserror::Error; +use tokio::sync::mpsc; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -81,11 +82,34 @@ pub struct ContainerSpec { pub privileged: bool, } +/// Runtime-agnostic input for [`ContainerRuntime::build`]. The +/// lifecycle orchestrator translates the parsed `devcontainer.json` +/// build stanza into this shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildSpec { + /// Tag to apply to the resulting image. + pub tag: ImageRef, + /// Absolute path to the build context directory. + pub context_dir: PathBuf, + /// Absolute path to the Dockerfile. + pub dockerfile: PathBuf, + /// `--build-arg` key/value pairs. + pub build_args: HashMap, + /// Multi-stage build target. + pub target: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContainerStatus { pub container_id: String, pub state: ContainerState, pub image_ref: Option, + /// Host-side paths bind-mounted into the container, in the order the + /// runtime reported them. Used by the dashboard to link containers + /// back to known workspace folders without relying on a name + /// convention. + #[serde(default)] + pub host_mounts: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -114,6 +138,39 @@ pub struct ExecResult { pub stderr: Vec, } +/// Which stream a [`LogChunk`] came from. The `system` variant is reserved +/// for orchestrator-injected log lines (e.g. "starting container…") that the +/// runtime did not produce itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LogStreamKind { + Stdout, + Stderr, + System, +} + +/// One line of container output, as produced by `runtime.logs(...)` or by +/// the lifecycle orchestrator's hook execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogChunk { + pub stream: LogStreamKind, + pub line: String, +} + +#[derive(Debug, Clone, Default)] +pub struct LogOptions { + /// Stream new output as it is produced. When false, returns historical + /// logs and closes the channel. + pub follow: bool, + /// If set, only the last `tail` lines of historical output are returned. + pub tail: Option, +} + +/// Streaming log handle. Dropping the underlying receiver should be enough +/// to cause the backend to abort its log task; implementations are expected +/// to detect a closed channel and stop the child process. +pub type LogStream = mpsc::Receiver; + /// The seam through which the lifecycle orchestrator drives any container /// engine. All operations are async; long-running ones (logs, events, /// `exec` with PTY) belong with the streaming APIs added in step 7. @@ -127,6 +184,22 @@ pub trait ContainerRuntime: Send + Sync { async fn probe(&self) -> Result; async fn pull(&self, image: &ImageRef) -> Result<(), ContainerRuntimeError>; + /// Build an image from a Dockerfile. Backends that do not support + /// building must return [`ContainerRuntimeError::Unsupported`]. The + /// optional `log_sink` receives stdout/stderr lines as the build + /// progresses; the same channel pattern as [`Self::logs`] applies + /// (closing the receiver tells the backend to abort). + async fn build( + &self, + spec: &BuildSpec, + log_sink: Option>, + ) -> Result { + let _ = (spec, log_sink); + Err(ContainerRuntimeError::Unsupported(format!( + "{:?} backend does not support building images", + self.id() + ))) + } async fn create(&self, spec: &ContainerSpec) -> Result; async fn start(&self, container_id: &str) -> Result<(), ContainerRuntimeError>; async fn stop(&self, container_id: &str) -> Result<(), ContainerRuntimeError>; @@ -138,6 +211,15 @@ pub trait ContainerRuntime: Send + Sync { container_id: &str, options: &ExecOptions, ) -> Result; + + /// Stream container logs. When `options.follow` is true, the returned + /// receiver yields lines until the container exits or the receiver is + /// dropped (which should abort the backend's log task). + async fn logs( + &self, + container_id: &str, + options: &LogOptions, + ) -> Result; } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/devcontainer/lifecycle.rs b/src-tauri/src/devcontainer/lifecycle.rs index 24ecf5b43..82bd44403 100644 --- a/src-tauri/src/devcontainer/lifecycle.rs +++ b/src-tauri/src/devcontainer/lifecycle.rs @@ -1,12 +1,892 @@ -//! Lifecycle orchestrator. Step 7 of the conversion roadmap fleshes this -//! out — running `initializeCommand` on the host, then driving the -//! `onCreate`/`updateContent`/`postCreate`/`postStart`/`postAttach` hooks -//! against the selected `ContainerRuntime`, streaming logs to the WebView -//! via `devcontainer://log` Tauri events. +//! Lifecycle orchestrator. +//! +//! Translates a [`ParsedDevContainer`] (posted from the WebView spec slice) +//! into a runtime-agnostic [`crate::container::ContainerSpec`] and drives +//! the selected [`crate::container::ContainerRuntime`] through pull → create +//! → start → lifecycle-hook execution. Per-workspace state is keyed by +//! `workspace_id`: +//! +//! * the parsed `devcontainer.json` (set by `submit_parsed_devcontainer`), +//! * the active `container_id` once `container_up` has run, +//! * a tokio `Mutex` so concurrent commands on the same workspace serialise. +//! +//! Status changes and hook output are emitted to the WebView as +//! `devcontainer://status` and `devcontainer://log` Tauri events. The +//! lifecycle commands in `crate::commands::lifecycle` are thin wrappers +//! around the methods on this struct. +//! +//! All event emission is funnelled through [`EventSink`] so the orchestrator +//! body can be unit-tested without a real `tauri::AppHandle`. -use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +use parking_lot::RwLock; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use thiserror::Error; +use tokio::sync::Mutex; +use tracing::{debug, error, info, warn}; + +use crate::container::{ + BuildSpec, ContainerRuntime, ContainerRuntimeError, ExecOptions, ImageRef, LogChunk, + LogStreamKind, RuntimeRegistry, +}; +use crate::devcontainer::translate::{ + parse_image_ref, to_container_spec, DevContainerBuild, LifecycleCommand, ParsedDevContainer, +}; + +/// Reject only what we genuinely cannot run yet. Today that's compose; +/// `image` and `build` (Dockerfile) are both supported. +fn validate_supported(parsed: &ParsedDevContainer) -> Result<(), LifecycleError> { + if parsed.docker_compose_file.is_some() { + return Err(LifecycleError::Unsupported( + "`dockerComposeFile` is not supported \u{2014} this app uses a one-container-per-repo model. Replace the compose file with an `image` or `build` stanza.".into(), + )); + } + if parsed.image.is_none() && parsed.build.is_none() { + return Err(LifecycleError::Unsupported( + "devcontainer.json must specify either `image` or `build`".into(), + )); + } + Ok(()) +} + +/// Coerce an arbitrary slug into a valid OCI image tag fragment: +/// lowercase, `[a-z0-9._-]` only, max 128 chars. Empty/all-bad input +/// collapses to `workspace` so we always produce a runnable tag. +fn sanitize_image_tag(input: &str) -> String { + let mut out: String = input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + while out.starts_with(['-', '.', '_']) { + out.remove(0); + } + while out.ends_with(['-', '.', '_']) { + out.pop(); + } + if out.is_empty() { + out.push_str("workspace"); + } + if out.len() > 128 { + out.truncate(128); + } + out +} + +#[derive(Debug, Error)] +pub enum LifecycleError { + #[error("no devcontainer.json submitted for workspace {0}")] + NoConfig(String), + #[error("{stage} failed: {source}")] + Stage { + stage: &'static str, + #[source] + source: ContainerRuntimeError, + }, + #[error("hook {label} exited with {exit_code}{stderr_suffix}", stderr_suffix = if stderr_tail.is_empty() { String::new() } else { format!(": {}", stderr_tail) })] + Hook { + label: &'static str, + exit_code: i32, + stderr_tail: String, + }, + #[error("unsupported devcontainer.json: {0}")] + Unsupported(String), +} + +impl From for LifecycleError { + fn from(source: ContainerRuntimeError) -> Self { + Self::Stage { + stage: "runtime", + source, + } + } +} + +fn stage( + stage: &'static str, + r: Result, +) -> Result { + r.map_err(|source| LifecycleError::Stage { stage, source }) +} + +/// Emission seam used by the orchestrator. Production code uses +/// [`TauriSink`]; tests use an in-memory [`CapturingSink`]. +pub trait EventSink: Send + Sync { + fn status( + &self, + workspace_id: &str, + state: &str, + container_id: Option<&str>, + image_ref: Option<&str>, + error: Option<&str>, + ); + fn log(&self, workspace_id: &str, stream: LogStreamKind, line: &str); +} + +/// Default sink that forwards events to the running Tauri app. +pub struct TauriSink<'a>(pub &'a AppHandle); + +impl EventSink for TauriSink<'_> { + fn status( + &self, + workspace_id: &str, + state: &str, + container_id: Option<&str>, + image_ref: Option<&str>, + error: Option<&str>, + ) { + let _ = self.0.emit( + "devcontainer://status", + StatusEvent { + workspace_id, + state, + container_id, + image_ref, + error, + }, + ); + } + + fn log(&self, workspace_id: &str, stream: LogStreamKind, line: &str) { + let stream_str = match stream { + LogStreamKind::Stdout => "stdout", + LogStreamKind::Stderr => "stderr", + LogStreamKind::System => "system", + }; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let _ = self.0.emit( + "devcontainer://log", + LogEvent { + workspace_id, + stream: stream_str, + line, + ts, + }, + ); + } +} + +/// Snapshot of a workspace's container state as exposed to the WebView. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LifecycleStatus { + pub workspace_id: String, + pub state: &'static str, + pub container_id: Option, + pub image_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Default)] +struct WorkspaceSlot { + parsed: Option, + container_id: Option, + last_image_ref: Option, + last_state: Option<&'static str>, + last_error: Option, + /// Host path of the repo this slot represents. Recorded by `up` so + /// `remove` can re-derive the container name after an app restart + /// or when no `container_id` was ever recorded. + host_workspace: Option, +} + +#[derive(Default)] +pub struct LifecycleOrchestrator { + /// Per-workspace persistent fields (parsed config, current container id, + /// last status) read/written under a short-lived `parking_lot::RwLock`. + slots: RwLock>, + /// Per-workspace tokio mutex serialising mutating commands. Held across + /// awaits so we use `tokio::sync::Mutex` rather than `parking_lot`. + locks: RwLock>>>, +} + +impl std::fmt::Debug for LifecycleOrchestrator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LifecycleOrchestrator") + .field("workspaces", &self.slots.read().len()) + .finish() + } +} + +impl LifecycleOrchestrator { + pub fn new() -> Self { + Self::default() + } + + /// Store the parsed config for `workspace_id`. Replaces any prior value. + pub fn set_parsed_config(&self, workspace_id: &str, parsed: ParsedDevContainer) { + let mut map = self.slots.write(); + let slot = map.entry(workspace_id.to_string()).or_default(); + slot.parsed = Some(parsed); + } + + /// Read-only snapshot helper used by `container_status` when no + /// runtime call is needed. + pub fn snapshot(&self, workspace_id: &str) -> LifecycleStatus { + let map = self.slots.read(); + if let Some(slot) = map.get(workspace_id) { + return LifecycleStatus { + workspace_id: workspace_id.to_string(), + state: slot.last_state.unwrap_or("absent"), + container_id: slot.container_id.clone(), + image_ref: slot.last_image_ref.clone(), + error: slot.last_error.clone(), + }; + } + LifecycleStatus { + workspace_id: workspace_id.to_string(), + state: "absent", + container_id: None, + image_ref: None, + error: None, + } + } + + fn lock_for(&self, workspace_id: &str) -> Arc> { + let mut map = self.locks.write(); + map.entry(workspace_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + fn parsed(&self, workspace_id: &str) -> Result { + self.slots + .read() + .get(workspace_id) + .and_then(|s| s.parsed.clone()) + .ok_or_else(|| LifecycleError::NoConfig(workspace_id.to_string())) + } + + fn record_state( + &self, + workspace_id: &str, + state: &'static str, + container_id: Option<&str>, + image_ref: Option<&str>, + ) { + let mut map = self.slots.write(); + let slot = map.entry(workspace_id.to_string()).or_default(); + slot.last_state = Some(state); + slot.last_error = None; + if let Some(cid) = container_id { + slot.container_id = Some(cid.to_string()); + } + if let Some(img) = image_ref { + slot.last_image_ref = Some(img.to_string()); + } + } + + fn record_error(&self, workspace_id: &str, message: &str) { + let mut map = self.slots.write(); + let slot = map.entry(workspace_id.to_string()).or_default(); + slot.last_state = Some("error"); + slot.last_error = Some(message.to_string()); + } + + /// Public entry point used by `container_up`. Wraps [`Self::up_with_sink`] + /// with a [`TauriSink`]. + pub async fn up( + &self, + app: &AppHandle, + registry: &RuntimeRegistry, + workspace_id: &str, + host_workspace: &Path, + ) -> Result { + self.up_with_sink(&TauriSink(app), registry, workspace_id, host_workspace) + .await + } + + /// Drive `pull → create → start` and run the post-create/start/attach + /// lifecycle hooks against the selected runtime. Sink-parameterised so + /// tests can capture the emitted events without a real Tauri app. + pub async fn up_with_sink( + &self, + sink: &dyn EventSink, + registry: &RuntimeRegistry, + workspace_id: &str, + host_workspace: &Path, + ) -> Result { + let lock = self.lock_for(workspace_id); + let _guard = lock.lock().await; + + // Record the host path so `remove` can re-derive the container + // name even if `up` fails before `container_id` is recorded. + { + let mut map = self.slots.write(); + map.entry(workspace_id.to_string()) + .or_default() + .host_workspace = Some(host_workspace.to_path_buf()); + } + + let result = self + .up_inner(sink, registry, workspace_id, host_workspace) + .await; + if let Err(err) = &result { + self.report_failure(sink, workspace_id, "up", err); + } + result + } + + /// Decide whether to pull a pre-built image or build one from a + /// Dockerfile, returning the resulting [`ImageRef`]. Build output + /// is streamed to `sink` line-by-line. + async fn resolve_image( + &self, + sink: &dyn EventSink, + runtime: &dyn ContainerRuntime, + workspace_id: &str, + host_workspace: &Path, + parsed: &ParsedDevContainer, + ) -> Result { + if let Some(build) = parsed.build.as_ref() { + let build_spec = self.resolve_build_spec(workspace_id, host_workspace, parsed, build); + sink.status( + workspace_id, + "building", + None, + Some(&build_spec.tag.repository), + None, + ); + sink.log( + workspace_id, + LogStreamKind::System, + &format!( + "building image {} from {}", + build_spec.tag.repository, + build_spec.dockerfile.display() + ), + ); + info!( + workspace = workspace_id, + tag = %build_spec.tag.repository, + dockerfile = %build_spec.dockerfile.display(), + context = %build_spec.context_dir.display(), + "stage=build begin" + ); + + // Bounded channel so a slow consumer can't grow memory. + let (tx, mut rx) = tokio::sync::mpsc::channel::(256); + + // Forward chunks to the sink *as they arrive* (don't buffer + // until the build completes — we want live progress in the + // dashboard log pane). We can't `tokio::spawn` the pump + // because `sink: &dyn EventSink` is not 'static, so we run + // it inline via `tokio::join!` on the same task. + let build_fut = runtime.build(&build_spec, Some(tx)); + let pump_fut = async { + while let Some(chunk) = rx.recv().await { + sink.log(workspace_id, chunk.stream, &chunk.line); + } + }; + let (result, ()) = tokio::join!(build_fut, pump_fut); + let image_ref = stage("build", result)?; + info!(workspace = workspace_id, image = %image_ref.repository, "stage=build done"); + Ok(image_ref) + } else { + let image_str = parsed + .image + .as_deref() + .expect("validate_supported guarantees image or build"); + let image_ref = parse_image_ref(image_str); + sink.status( + workspace_id, + "pulling", + None, + Some(&image_ref.repository), + None, + ); + sink.log( + workspace_id, + LogStreamKind::System, + &format!("pulling image {}", image_ref.repository), + ); + info!(workspace = workspace_id, image = %image_ref.repository, "stage=pull begin"); + stage("pull", runtime.pull(&image_ref).await)?; + info!(workspace = workspace_id, image = %image_ref.repository, "stage=pull done"); + Ok(image_ref) + } + } + + /// Resolve the parsed build stanza into an absolute [`BuildSpec`]. + /// Per the upstream spec, `dockerfile` and `context` are relative to + /// the `.devcontainer/` folder (the parent of `devcontainer.json`). + fn resolve_build_spec( + &self, + workspace_id: &str, + host_workspace: &Path, + parsed: &ParsedDevContainer, + build: &DevContainerBuild, + ) -> BuildSpec { + let cfg_dir: std::path::PathBuf = parsed + .config_file_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| host_workspace.join(".devcontainer")); + + let dockerfile = cfg_dir.join(build.dockerfile.as_deref().unwrap_or("Dockerfile")); + let context_dir = cfg_dir.join(build.context.as_deref().unwrap_or(".")); + + let workspace_slug = host_workspace + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(workspace_id); + let tag = parse_image_ref(&format!( + "devcontainer-{}:latest", + sanitize_image_tag(workspace_slug) + )); + + BuildSpec { + tag, + context_dir, + dockerfile, + build_args: build.args.clone(), + target: build.target.clone(), + } + } + + async fn up_inner( + &self, + sink: &dyn EventSink, + registry: &RuntimeRegistry, + workspace_id: &str, + host_workspace: &Path, + ) -> Result { + let parsed = self.parsed(workspace_id)?; + let runtime = registry.selected(); + validate_supported(&parsed)?; + + // Resolve the image: either pull a pre-built one, or build from a + // Dockerfile. The result is the ImageRef we hand to `create`. + let image_ref = self + .resolve_image(sink, runtime.as_ref(), workspace_id, host_workspace, &parsed) + .await?; + let spec = to_container_spec(&parsed, image_ref.clone(), workspace_id, host_workspace); + + info!( + workspace = workspace_id, + runtime = ?runtime.id(), + image = %spec.image.repository, + "lifecycle.up starting" + ); + + sink.status(workspace_id, "creating", None, Some(&spec.image.repository), None); + sink.log( + workspace_id, + LogStreamKind::System, + &format!("creating container {}", spec.name), + ); + debug!(workspace = workspace_id, "stage=create begin"); + let container_id = match runtime.create(&spec).await { + Ok(id) => id, + // Apple's `container` CLI returns: "failed to create container + // (cause: \"exists: \"container already exists: NAME\"\")". + // Other backends use varied phrasing for the same condition. + // We treat "already exists" as recoverable: adopt the existing + // container by its configured name and continue. The user can + // hit Rebuild for a fresh one. + Err(ContainerRuntimeError::Backend(msg)) if is_already_exists(&msg) => { + warn!( + workspace = workspace_id, + name = %spec.name, + "container already exists; adopting by name" + ); + sink.log( + workspace_id, + LogStreamKind::System, + &format!( + "container `{}` already exists; adopting it (use Rebuild for a fresh container)", + spec.name + ), + ); + spec.name.clone() + } + Err(err) => return Err(stage::<()>("create", Err(err)).unwrap_err()), + }; + debug!(workspace = workspace_id, container = %container_id, "stage=create end"); + + self.record_state( + workspace_id, + "created", + Some(&container_id), + Some(&spec.image.repository), + ); + sink.status( + workspace_id, + "created", + Some(&container_id), + Some(&spec.image.repository), + None, + ); + + sink.log( + workspace_id, + LogStreamKind::System, + &format!("starting container {container_id}"), + ); + debug!(workspace = workspace_id, "stage=start begin"); + stage("start", runtime.start(&container_id).await)?; + debug!(workspace = workspace_id, "stage=start end"); + self.record_state(workspace_id, "running", Some(&container_id), None); + sink.status( + workspace_id, + "running", + Some(&container_id), + Some(&spec.image.repository), + None, + ); + + // Lifecycle hooks. `initializeCommand` runs on the host (intentionally + // not implemented in the MVP — host-side execution is gated on the + // permission model that lands in Phase 2). The remaining hooks run + // inside the container via `runtime.exec`. + for (label, hook) in [ + ("onCreateCommand", parsed.on_create_command.as_ref()), + ( + "updateContentCommand", + parsed.update_content_command.as_ref(), + ), + ("postCreateCommand", parsed.post_create_command.as_ref()), + ("postStartCommand", parsed.post_start_command.as_ref()), + ("postAttachCommand", parsed.post_attach_command.as_ref()), + ] { + if let Some(cmd) = hook { + run_hook( + sink, + runtime.as_ref(), + workspace_id, + &container_id, + &spec, + label, + cmd, + ) + .await?; + } + } + + info!(workspace = workspace_id, container = %container_id, "lifecycle.up running"); + Ok(LifecycleStatus { + workspace_id: workspace_id.to_string(), + state: "running", + container_id: Some(container_id), + image_ref: Some(spec.image.repository), + error: None, + }) + } + + pub async fn stop( + &self, + app: &AppHandle, + registry: &RuntimeRegistry, + workspace_id: &str, + ) -> Result { + self.stop_with_sink(&TauriSink(app), registry, workspace_id).await + } + + pub async fn stop_with_sink( + &self, + sink: &dyn EventSink, + registry: &RuntimeRegistry, + workspace_id: &str, + ) -> Result { + let lock = self.lock_for(workspace_id); + let _guard = lock.lock().await; + + let runtime = registry.selected(); + let cid = self + .slots + .read() + .get(workspace_id) + .and_then(|s| s.container_id.clone()); + if let Some(cid) = cid { + info!(workspace = workspace_id, container = %cid, "lifecycle.stop"); + if let Err(err) = stage("stop", runtime.stop(&cid).await) { + self.report_failure(sink, workspace_id, "stop", &err); + return Err(err); + } + self.record_state(workspace_id, "stopped", Some(&cid), None); + sink.status(workspace_id, "stopped", Some(&cid), None, None); + } else { + debug!(workspace = workspace_id, "stop: no container recorded"); + self.record_state(workspace_id, "absent", None, None); + } + Ok(self.snapshot(workspace_id)) + } + + pub async fn remove( + &self, + app: &AppHandle, + registry: &RuntimeRegistry, + workspace_id: &str, + ) -> Result { + self.remove_with_sink(&TauriSink(app), registry, workspace_id).await + } + + pub async fn remove_with_sink( + &self, + sink: &dyn EventSink, + registry: &RuntimeRegistry, + workspace_id: &str, + ) -> Result { + let lock = self.lock_for(workspace_id); + let _guard = lock.lock().await; + + let runtime = registry.selected(); + // Prefer the recorded id, but if we don't have one (e.g. the app + // was restarted, or `up` failed before recording it) fall back to + // the container name we *would have* used. This is what the + // Remove button needs to work after a partial/failed Up. + let target = self + .slots + .read() + .get(workspace_id) + .and_then(|s| s.container_id.clone()) + .or_else(|| { + let map = self.slots.read(); + let slot = map.get(workspace_id)?; + let host = slot.host_workspace.as_ref()?; + Some(derive_container_name(workspace_id, host)) + }); + if let Some(cid) = target { + info!(workspace = workspace_id, container = %cid, "lifecycle.remove"); + match runtime.remove(&cid, true).await { + Ok(()) => {} + // Treat "not found" as success — the desired end state + // (no container) is already true. + Err(ContainerRuntimeError::Backend(msg)) if is_not_found(&msg) => { + info!( + workspace = workspace_id, + container = %cid, + "remove: container not found; treating as success" + ); + sink.log( + workspace_id, + LogStreamKind::System, + &format!("container `{cid}` not found (already removed)"), + ); + } + Err(err) => { + let lerr = stage::<()>("remove", Err(err)).unwrap_err(); + self.report_failure(sink, workspace_id, "remove", &lerr); + return Err(lerr); + } + } + let mut map = self.slots.write(); + if let Some(slot) = map.get_mut(workspace_id) { + slot.container_id = None; + slot.last_state = Some("absent"); + slot.last_image_ref = None; + slot.last_error = None; + } + sink.status(workspace_id, "absent", None, None, None); + } else { + debug!(workspace = workspace_id, "remove: no container or parsed config"); + self.record_state(workspace_id, "absent", None, None); + sink.status(workspace_id, "absent", None, None, None); + } + Ok(self.snapshot(workspace_id)) + } + + pub async fn rebuild( + &self, + app: &AppHandle, + registry: &RuntimeRegistry, + workspace_id: &str, + host_workspace: &Path, + ) -> Result { + self.rebuild_with_sink(&TauriSink(app), registry, workspace_id, host_workspace) + .await + } + + pub async fn rebuild_with_sink( + &self, + sink: &dyn EventSink, + registry: &RuntimeRegistry, + workspace_id: &str, + host_workspace: &Path, + ) -> Result { + // `up` and `remove` already grab the per-workspace lock, so call + // them sequentially without holding it ourselves. + info!(workspace = workspace_id, "lifecycle.rebuild"); + self.remove_with_sink(sink, registry, workspace_id).await?; + self.up_with_sink(sink, registry, workspace_id, host_workspace) + .await + } + + /// Surface a failed lifecycle stage to logs and the WebView so the user + /// can see *why*. Without this, an `Err` from a `runtime.*` call only + /// surfaces as the rejected Tauri command Promise — the `error` + /// status event is never emitted and the in-app terminal stays silent. + fn report_failure( + &self, + sink: &dyn EventSink, + workspace_id: &str, + op: &'static str, + err: &LifecycleError, + ) { + let detail = err.to_string(); + error!( + workspace = workspace_id, + op, + error = %detail, + "lifecycle operation failed" + ); + self.record_error(workspace_id, &detail); + sink.log( + workspace_id, + LogStreamKind::Stderr, + &format!("{op} failed: {detail}"), + ); + // Preserve any container linkage we've already recorded so the + // dashboard keeps showing the repo↔container relationship even + // when a hook (postCreateCommand, etc) fails after the container + // has been created or adopted. + let (cid, image) = { + let map = self.slots.read(); + map.get(workspace_id) + .map(|s| (s.container_id.clone(), s.last_image_ref.clone())) + .unwrap_or((None, None)) + }; + sink.status( + workspace_id, + "error", + cid.as_deref(), + image.as_deref(), + Some(&detail), + ); + } +} + +/// Detect a backend "container already exists" error message regardless +/// of which CLI produced it. Apple `container` says +/// `exists: "container already exists: NAME"`; Docker/Podman wording +/// includes phrases like `is already in use` or `already exists`. +fn is_already_exists(msg: &str) -> bool { + let m = msg.to_ascii_lowercase(); + m.contains("already exists") || m.contains("is already in use") +} + +/// Detect a backend "no such container" message. Apple says +/// `not found: NAME`; Docker says `No such container`. +fn is_not_found(msg: &str) -> bool { + let m = msg.to_ascii_lowercase(); + m.contains("not found") || m.contains("no such container") +} + +/// Re-derive the container name we *would have* used for `up`, so the +/// Remove button can clean up after a failed/restarted Up that never +/// got to record a container_id. +fn derive_container_name(workspace_id: &str, host_workspace: &std::path::Path) -> String { + use crate::devcontainer::translate::{derive_name_from_path, sanitize_entity_name}; + let raw = derive_name_from_path(host_workspace, workspace_id); + sanitize_entity_name(&raw).unwrap_or_else(|| format!("devcontainer-{workspace_id}")) +} + +async fn run_hook( + sink: &dyn EventSink, + runtime: &dyn ContainerRuntime, + workspace_id: &str, + container_id: &str, + spec: &crate::container::ContainerSpec, + label: &'static str, + cmd: &LifecycleCommand, +) -> Result<(), LifecycleError> { + let argv = cmd.to_argv(); + if argv.is_empty() { + return Ok(()); + } + info!( + workspace = workspace_id, + container = container_id, + hook = label, + "running lifecycle hook" + ); + sink.log( + workspace_id, + LogStreamKind::System, + &format!("running {label}: {}", argv.join(" ")), + ); + let opts = ExecOptions { + command: argv, + workdir: spec.workdir.clone(), + env: spec.env.clone(), + user: spec.user.clone(), + tty: false, + }; + let result = stage("hook", runtime.exec(container_id, &opts).await)?; + let stdout_text = String::from_utf8_lossy(&result.stdout); + let stderr_text = String::from_utf8_lossy(&result.stderr); + for line in stdout_text.lines() { + if !line.is_empty() { + sink.log(workspace_id, LogStreamKind::Stdout, line); + } + } + for line in stderr_text.lines() { + if !line.is_empty() { + sink.log(workspace_id, LogStreamKind::Stderr, line); + } + } + if result.exit_code != 0 { + // Keep the tail of stderr so the user sees what failed without + // having to open the log pane. 1KiB is plenty for a one-line + // "command not found" / "No such file" diagnostic. + let mut tail = stderr_text.trim().to_string(); + if tail.len() > 1024 { + let start = tail.len() - 1024; + tail = format!("…{}", &tail[start..]); + } + warn!( + workspace = workspace_id, + hook = label, + exit_code = result.exit_code, + stderr = %tail, + "lifecycle hook exited non-zero" + ); + return Err(LifecycleError::Hook { + label, + exit_code: result.exit_code, + stderr_tail: tail, + }); + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct StatusEvent<'a> { + workspace_id: &'a str, + state: &'a str, + container_id: Option<&'a str>, + image_ref: Option<&'a str>, + error: Option<&'a str>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LogEvent<'a> { + workspace_id: &'a str, + stream: &'a str, + line: &'a str, + ts: u128, +} + +/// `devcontainer.json` lifecycle hook identifier. Re-exported from the +/// orchestrator so command handlers can use it without importing +/// `translate.rs` directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub enum LifecycleHook { InitializeCommand, @@ -23,3 +903,507 @@ impl LifecycleHook { matches!(self, Self::InitializeCommand) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::container::traits::{ + ContainerStatus, ExecResult, LogStream, RuntimeAvailability, RuntimeId, + }; + use crate::container::{ContainerRuntime, ImageRef, RuntimeRegistry}; + use async_trait::async_trait; + use parking_lot::Mutex as PlMutex; + use std::path::PathBuf; + use std::sync::Arc; + + #[test] + fn lifecycle_command_argv_shapes() { + let s = LifecycleCommand::Single("echo hi && ls".into()); + assert_eq!(s.to_argv(), vec!["/bin/sh", "-c", "echo hi && ls"]); + let m = LifecycleCommand::Multiple(vec!["true".into(), "yes".into()]); + assert_eq!(m.to_argv(), vec!["true", "yes"]); + } + + #[test] + fn snapshot_is_absent_for_unknown_workspaces() { + let o = LifecycleOrchestrator::new(); + let s = o.snapshot("missing"); + assert_eq!(s.state, "absent"); + assert!(s.container_id.is_none()); + } + + #[test] + fn set_parsed_config_persists_across_calls() { + let o = LifecycleOrchestrator::new(); + let p = ParsedDevContainer { + image: Some("ubuntu".into()), + ..Default::default() + }; + o.set_parsed_config("ws-1", p); + assert!(o.parsed("ws-1").is_ok()); + assert!(matches!(o.parsed("ws-2"), Err(LifecycleError::NoConfig(_)))); + } + + #[test] + fn record_state_overwrites_last_state() { + let o = LifecycleOrchestrator::new(); + o.record_state("ws", "creating", Some("cid-1"), Some("ubuntu")); + let s = o.snapshot("ws"); + assert_eq!(s.state, "creating"); + assert_eq!(s.container_id.as_deref(), Some("cid-1")); + o.record_state("ws", "running", None, None); + assert_eq!(o.snapshot("ws").state, "running"); + assert_eq!(o.snapshot("ws").container_id.as_deref(), Some("cid-1")); + } + + // -------- test fixtures (capturing sink + scriptable fake runtime) -------- + + #[derive(Debug, Clone)] + #[allow(dead_code)] // container_id is captured for future assertions. + struct CapturedStatus { + state: String, + container_id: Option, + error: Option, + } + + #[derive(Debug, Clone)] + struct CapturedLog { + stream: &'static str, + line: String, + } + + #[derive(Default)] + struct CapturingSink { + statuses: PlMutex>, + logs: PlMutex>, + } + + impl EventSink for CapturingSink { + fn status( + &self, + _workspace_id: &str, + state: &str, + container_id: Option<&str>, + _image_ref: Option<&str>, + error: Option<&str>, + ) { + self.statuses.lock().push(CapturedStatus { + state: state.to_string(), + container_id: container_id.map(str::to_string), + error: error.map(str::to_string), + }); + } + fn log(&self, _workspace_id: &str, stream: LogStreamKind, line: &str) { + let s = match stream { + LogStreamKind::Stdout => "stdout", + LogStreamKind::Stderr => "stderr", + LogStreamKind::System => "system", + }; + self.logs.lock().push(CapturedLog { + stream: s, + line: line.to_string(), + }); + } + } + + /// Per-stage scripted outcome for [`FakeRuntime`]. + #[derive(Default)] + struct FakeScript { + pull_err: Option, + build_err: Option, + create_err: Option, + start_err: Option, + /// Lines emitted on the build log channel before the build + /// returns. Each is sent as `(stream, line)`. + build_log: Vec<(LogStreamKind, String)>, + } + + struct FakeRuntime { + script: PlMutex, + build_calls: PlMutex>, + } + + impl FakeRuntime { + fn new(script: FakeScript) -> Self { + Self { + script: PlMutex::new(script), + build_calls: PlMutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl ContainerRuntime for FakeRuntime { + fn id(&self) -> RuntimeId { + RuntimeId::AppleContainers + } + async fn probe(&self) -> Result { + Ok(RuntimeAvailability { + available: true, + version: Some("fake".into()), + reason: None, + }) + } + async fn pull(&self, _image: &ImageRef) -> Result<(), ContainerRuntimeError> { + if let Some(e) = self.script.lock().pull_err.take() { + return Err(ContainerRuntimeError::Backend(e)); + } + Ok(()) + } + async fn build( + &self, + spec: &crate::container::BuildSpec, + log_sink: Option>, + ) -> Result { + self.build_calls.lock().push(spec.clone()); + // Drain any scripted log lines into the sink, mirroring how + // the real backend forwards `container build` output. + let lines: Vec<(LogStreamKind, String)> = + std::mem::take(&mut self.script.lock().build_log); + if let Some(tx) = log_sink { + for (stream, line) in lines { + let _ = tx.send(LogChunk { stream, line }).await; + } + } + if let Some(e) = self.script.lock().build_err.take() { + return Err(ContainerRuntimeError::Backend(e)); + } + Ok(spec.tag.clone()) + } + async fn create( + &self, + _spec: &crate::container::ContainerSpec, + ) -> Result { + if let Some(e) = self.script.lock().create_err.take() { + return Err(ContainerRuntimeError::Backend(e)); + } + Ok("fake-cid-1".into()) + } + async fn start(&self, _container_id: &str) -> Result<(), ContainerRuntimeError> { + if let Some(e) = self.script.lock().start_err.take() { + return Err(ContainerRuntimeError::Backend(e)); + } + Ok(()) + } + async fn stop(&self, _container_id: &str) -> Result<(), ContainerRuntimeError> { + Ok(()) + } + async fn remove( + &self, + _container_id: &str, + _force: bool, + ) -> Result<(), ContainerRuntimeError> { + Ok(()) + } + async fn inspect( + &self, + container_id: &str, + ) -> Result { + Ok(ContainerStatus { + container_id: container_id.into(), + state: crate::container::ContainerState::Running, + image_ref: None, + host_mounts: Vec::new(), + }) + } + async fn list(&self) -> Result, ContainerRuntimeError> { + Ok(vec![]) + } + async fn exec( + &self, + _container_id: &str, + _options: &ExecOptions, + ) -> Result { + Ok(ExecResult { + exit_code: 0, + stdout: b"hook stdout line\n".to_vec(), + stderr: vec![], + }) + } + async fn logs( + &self, + _container_id: &str, + _options: &crate::container::LogOptions, + ) -> Result { + let (_tx, rx) = tokio::sync::mpsc::channel(1); + Ok(rx) + } + } + + fn registry_with(runtime: FakeRuntime) -> RuntimeRegistry { + RuntimeRegistry::with_single(RuntimeId::AppleContainers, Arc::new(runtime)) + } + + fn parsed_with_image(img: &str) -> ParsedDevContainer { + ParsedDevContainer { + image: Some(img.into()), + ..Default::default() + } + } + + // -------- end-to-end: happy path emits the expected event sequence -------- + + #[tokio::test] + async fn up_emits_pulling_creating_running_in_order() { + let o = LifecycleOrchestrator::new(); + o.set_parsed_config("ws", parsed_with_image("ubuntu:24.04")); + let registry = registry_with(FakeRuntime::new(FakeScript::default())); + let sink = CapturingSink::default(); + + let status = o + .up_with_sink(&sink, ®istry, "ws", &PathBuf::from("/tmp/ws")) + .await + .expect("up should succeed"); + assert_eq!(status.state, "running"); + + let states: Vec = sink + .statuses + .lock() + .iter() + .map(|s| s.state.clone()) + .collect(); + assert_eq!(states, vec!["pulling", "creating", "created", "running"]); + + let log_lines: Vec = + sink.logs.lock().iter().map(|l| l.line.clone()).collect(); + assert!( + log_lines.iter().any(|l| l.starts_with("pulling image ubuntu")), + "missing pull log line; got: {log_lines:?}" + ); + assert!( + log_lines.iter().any(|l| l.contains("creating container")), + "missing create log line; got: {log_lines:?}" + ); + } + + // -------- failure mode: pull fails -> error status with detail surfaces -------- + + #[tokio::test] + async fn up_pull_failure_surfaces_error_status_and_log() { + let o = LifecycleOrchestrator::new(); + o.set_parsed_config("ws", parsed_with_image("ubuntu:24.04")); + let registry = registry_with(FakeRuntime::new(FakeScript { + pull_err: Some("manifest unknown for ubuntu:24.04".into()), + ..Default::default() + })); + let sink = CapturingSink::default(); + + let err = o + .up_with_sink(&sink, ®istry, "ws", &PathBuf::from("/tmp/ws")) + .await + .expect_err("up should fail when pull fails"); + + // The error returned to the caller carries the stage *and* the + // backend detail so the Tauri command's `Err(e.to_string())` is + // useful in the UI. + let msg = err.to_string(); + assert!(msg.contains("pull"), "missing stage in error: {msg}"); + assert!( + msg.contains("manifest unknown"), + "missing backend detail in error: {msg}" + ); + + // The orchestrator must also push an `error` status event and a + // stderr log line so the in-app terminal/status pill update — this + // is the regression that gave us "error with no explanation". + let last = sink.statuses.lock().last().cloned().expect("status emitted"); + assert_eq!(last.state, "error"); + let detail = last.error.expect("error status carries detail"); + assert!( + detail.contains("manifest unknown"), + "error detail dropped: {detail}" + ); + + let stderr_lines: Vec = sink + .logs + .lock() + .iter() + .filter(|l| l.stream == "stderr") + .map(|l| l.line.clone()) + .collect(); + assert!( + stderr_lines.iter().any(|l| l.contains("up failed")), + "missing stderr log line; got: {stderr_lines:?}" + ); + + // And the recorded snapshot now reflects the error so subsequent + // `container_status` calls expose the same detail. + let snap = o.snapshot("ws"); + assert_eq!(snap.state, "error"); + assert!(snap + .error + .as_deref() + .unwrap_or("") + .contains("manifest unknown")); + } + + #[tokio::test] + async fn rebuild_runs_remove_then_up_and_recovers_to_running() { + let o = LifecycleOrchestrator::new(); + o.set_parsed_config("ws", parsed_with_image("ubuntu:24.04")); + // Pre-seed a stale container id so `remove` actually shells out. + o.record_state("ws", "stopped", Some("old-cid"), Some("ubuntu:24.04")); + let registry = registry_with(FakeRuntime::new(FakeScript::default())); + let sink = CapturingSink::default(); + + let status = o + .rebuild_with_sink(&sink, ®istry, "ws", &PathBuf::from("/tmp/ws")) + .await + .expect("rebuild should succeed"); + assert_eq!(status.state, "running"); + assert_eq!(status.container_id.as_deref(), Some("fake-cid-1")); + + let states: Vec = sink + .statuses + .lock() + .iter() + .map(|s| s.state.clone()) + .collect(); + // remove emits "absent" first; then up emits the usual sequence. + assert_eq!( + states, + vec!["absent", "pulling", "creating", "created", "running"] + ); + } + + // -------- build path: dockerfile-based config invokes runtime.build -------- + + #[tokio::test] + async fn up_with_dockerfile_build_invokes_runtime_build_and_emits_building_status() { + let o = LifecycleOrchestrator::new(); + let parsed = ParsedDevContainer { + name: Some("JupyterLite Demo".into()), + build: Some(DevContainerBuild { + dockerfile: Some("Dockerfile".into()), + context: Some("..".into()), + ..Default::default() + }), + config_file_path: Some(PathBuf::from( + "/tmp/take-two/.devcontainer/devcontainer.json", + )), + ..Default::default() + }; + o.set_parsed_config("ws", parsed); + + let runtime = Arc::new(FakeRuntime::new(FakeScript { + build_log: vec![ + (LogStreamKind::Stdout, "step 1/2: FROM python:3.13".into()), + (LogStreamKind::Stdout, "step 2/2: RUN apt-get update".into()), + ], + ..Default::default() + })); + let registry = RuntimeRegistry::with_single( + RuntimeId::AppleContainers, + runtime.clone() as Arc, + ); + let sink = CapturingSink::default(); + + let status = o + .up_with_sink(&sink, ®istry, "ws", &PathBuf::from("/tmp/take-two")) + .await + .expect("up should succeed for build-based config"); + assert_eq!(status.state, "running"); + + // The build was invoked with the right resolved paths. + let calls = runtime.build_calls.lock().clone(); + assert_eq!(calls.len(), 1, "expected one build call"); + let bs = &calls[0]; + assert_eq!( + bs.dockerfile, + PathBuf::from("/tmp/take-two/.devcontainer/Dockerfile"), + "dockerfile resolved relative to .devcontainer/" + ); + assert_eq!( + bs.context_dir, + PathBuf::from("/tmp/take-two/.devcontainer/.."), + "context resolved relative to .devcontainer/" + ); + assert!( + bs.tag.repository.starts_with("devcontainer-take-two"), + "synthesised tag should be workspace-scoped, got: {}", + bs.tag.repository + ); + + // Status sequence includes `building` (not `pulling`). + let states: Vec = sink + .statuses + .lock() + .iter() + .map(|s| s.state.clone()) + .collect(); + assert_eq!(states, vec!["building", "creating", "created", "running"]); + + // Build log lines surfaced to the dashboard. + let log_lines: Vec = + sink.logs.lock().iter().map(|l| l.line.clone()).collect(); + assert!( + log_lines.iter().any(|l| l.contains("step 1/2: FROM python")), + "expected build stdout in sink; got: {log_lines:?}" + ); + } + + // -------- failure mode: build fails -> error status with stderr surfaces -------- + + #[tokio::test] + async fn up_with_build_failure_surfaces_error_status_and_log() { + let o = LifecycleOrchestrator::new(); + let parsed = ParsedDevContainer { + build: Some(DevContainerBuild::default()), + config_file_path: Some(PathBuf::from( + "/tmp/ws/.devcontainer/devcontainer.json", + )), + ..Default::default() + }; + o.set_parsed_config("ws", parsed); + + let registry = registry_with(FakeRuntime::new(FakeScript { + build_err: Some("Dockerfile syntax error on line 3".into()), + ..Default::default() + })); + let sink = CapturingSink::default(); + + let err = o + .up_with_sink(&sink, ®istry, "ws", &PathBuf::from("/tmp/ws")) + .await + .expect_err("build error must propagate"); + let msg = err.to_string(); + assert!( + msg.contains("build failed") && msg.contains("Dockerfile syntax error"), + "expected detailed build error, got: {msg}" + ); + let states: Vec = sink + .statuses + .lock() + .iter() + .map(|s| s.state.clone()) + .collect(); + assert!( + states.contains(&"error".to_string()), + "expected error status; got: {states:?}" + ); + } + + // -------- compose is still rejected explicitly (one-container-per-repo) -------- + + #[tokio::test] + async fn up_with_compose_config_reports_unsupported_error() { + let o = LifecycleOrchestrator::new(); + let parsed = ParsedDevContainer { + docker_compose_file: Some(serde_json::json!("docker-compose.yml")), + ..Default::default() + }; + o.set_parsed_config("ws", parsed); + let registry = registry_with(FakeRuntime::new(FakeScript::default())); + let sink = CapturingSink::default(); + + let err = o + .up_with_sink(&sink, ®istry, "ws", &PathBuf::from("/tmp/ws")) + .await + .expect_err("compose config must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("dockerComposeFile") && msg.contains("one-container-per-repo"), + "expected compose-rejection error, got: {msg}" + ); + } +} diff --git a/src-tauri/src/devcontainer/translate.rs b/src-tauri/src/devcontainer/translate.rs index cda0752d2..5277e8ba4 100644 --- a/src-tauri/src/devcontainer/translate.rs +++ b/src-tauri/src/devcontainer/translate.rs @@ -1,7 +1,7 @@ //! Translate a parsed `devcontainer.json` (received from the WebView spec //! engine) into a [`ContainerSpec`] suitable for any -//! [`crate::container::ContainerRuntime`]. Implementation lands in step 7 of -//! the conversion roadmap. +//! [`crate::container::ContainerRuntime`]. Only the v1 subset of fields is +//! consumed; later phases enrich it. use std::path::PathBuf; @@ -9,41 +9,118 @@ use serde::{Deserialize, Serialize}; use crate::container::{ContainerSpec, ImageRef}; -/// Subset of the parsed `devcontainer.json` fields needed by the v1 MVP. -/// This is the contract sent from the WebView to the Rust host. Fields -/// outside this struct (Features, compose, etc.) are deferred to later -/// phases. +/// `devcontainer.json` lifecycle command: either a single string parsed by +/// the shell or an explicit argv array. Mirrors the upstream schema. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum LifecycleCommand { + Single(String), + Multiple(Vec), +} + +impl LifecycleCommand { + /// Render as the argv passed to `runtime.exec(...)`. Single strings are + /// run via `/bin/sh -c` so `&&`, redirects, etc. work; we deliberately + /// avoid `-lc` (login shell) so the hook does not pick up unrelated + /// profile scripts in the workspace image. + pub fn to_argv(&self) -> Vec { + match self { + LifecycleCommand::Single(s) => { + vec!["/bin/sh".to_string(), "-c".to_string(), s.clone()] + } + LifecycleCommand::Multiple(parts) => parts.clone(), + } + } +} + +/// Dockerfile-based build. Mirrors the upstream +/// `DevContainerFromDockerfileConfig.build` object. Paths are *not* +/// resolved here — they are interpreted relative to +/// [`ParsedDevContainer::config_file_path`]'s parent (the +/// `.devcontainer/` folder), per the upstream spec. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DevContainerBuild { + /// Dockerfile path relative to the `.devcontainer/` folder. Defaults + /// to `Dockerfile` when omitted, matching the spec. + #[serde(default)] + pub dockerfile: Option, + /// Build context directory relative to the `.devcontainer/` folder. + /// Defaults to `.` (the `.devcontainer/` folder itself). + #[serde(default)] + pub context: Option, + /// `--build-arg` values. + #[serde(default)] + pub args: std::collections::HashMap, + /// Multi-stage build target. + #[serde(default)] + pub target: Option, + /// Image tag(s) the upstream spec asks the build to be tagged with. + /// We honour these when set; otherwise we synthesise a workspace- + /// scoped tag. + #[serde(default)] + pub cache_from: Vec, +} + +/// Subset of the parsed `devcontainer.json` fields the v1 host consumes. +/// This is the contract sent from the WebView to the Rust host. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ParsedDevContainer { + #[serde(default)] pub name: Option, + /// Pre-built image reference. Mutually exclusive with `build` per + /// the spec; if both are set, `build` wins (matches upstream). + #[serde(default)] pub image: Option, + /// Dockerfile-based build config. When set, the host will run + /// `runtime.build(...)` to produce the image before creating the + /// container. + #[serde(default)] + pub build: Option, + /// Carried through so the host can surface a clear error. v1 does + /// not implement compose orchestration (one-container-per-repo). + #[serde(default, rename = "dockerComposeFile")] + pub docker_compose_file: Option, + /// Absolute path to the `devcontainer.json` that produced this + /// struct. Used to resolve build paths relative to its parent. + #[serde(default)] + pub config_file_path: Option, + #[serde(default)] pub workspace_folder: Option, + #[serde(default)] pub workspace_mount: Option, + #[serde(default)] pub mounts: Vec, + #[serde(default)] pub forward_ports: Vec, + #[serde(default)] pub remote_user: Option, + #[serde(default)] pub container_env: std::collections::HashMap, + #[serde(default)] pub remote_env: std::collections::HashMap>, + #[serde(default)] + pub on_create_command: Option, + #[serde(default)] + pub update_content_command: Option, + #[serde(default)] + pub post_create_command: Option, + #[serde(default)] + pub post_start_command: Option, + #[serde(default)] + pub post_attach_command: Option, } -/// Translate the parsed config into a runtime-agnostic [`ContainerSpec`]. -/// This is the minimal v1 implementation; later phases enrich it. +/// Translate the parsed config into a runtime-agnostic [`ContainerSpec`], +/// given the already-resolved [`ImageRef`] (the lifecycle decides whether +/// it came from `pull` or `build`). pub fn to_container_spec( parsed: &ParsedDevContainer, + image_ref: ImageRef, workspace_id: &str, host_workspace: &std::path::Path, ) -> ContainerSpec { - let image_ref = parsed - .image - .as_deref() - .map(parse_image_ref) - .unwrap_or_else(|| ImageRef { - registry: None, - repository: "mcr.microsoft.com/devcontainers/base".to_string(), - tag: Some("ubuntu".to_string()), - digest: None, - }); - let workspace_target = parsed.workspace_folder.clone().unwrap_or_else(|| { PathBuf::from("/workspaces").join( host_workspace @@ -82,13 +159,21 @@ pub fn to_container_spec( .map(|(k, v)| (k.clone(), v.clone())) .collect(); + let raw_name = derive_name_from_path(host_workspace, workspace_id); + let name = sanitize_entity_name(&raw_name) + .unwrap_or_else(|| format!("devcontainer-{workspace_id}")); + ContainerSpec { - name: parsed - .name - .clone() - .unwrap_or_else(|| format!("devcontainer-{workspace_id}")), + name, image: image_ref, - command: None, + // Devcontainers spec defaults `overrideCommand` to true: replace + // the image's CMD/ENTRYPOINT with a long-running no-op so the + // container stays alive for `exec`-driven workflows. Without + // this, base images whose CMD exits immediately (e.g. `bash` + // without a TTY) leave us with a "stopped" container the moment + // `start` returns and `exec` then fails with "no sandbox + // client exists: container is stopped". + command: Some(vec!["/bin/sh".into(), "-c".into(), "while sleep 2147483647; do :; done".into()]), workdir: Some(workspace_target), env, mounts, @@ -98,7 +183,73 @@ pub fn to_container_spec( } } -fn parse_image_ref(s: &str) -> ImageRef { +/// Derive a deterministic, repo-unique container name from the host +/// workspace path. Format is `-<8-hex>` where the hex is a +/// stable hash of the full absolute path. This guarantees: +/// +/// * Two repos called `take-two` in different parent directories don't +/// collide on the runtime's name index (the hash differs). +/// * Re-running `up` on the same repo produces the same name, so the +/// orchestrator's adopt-on-already-exists path can find it again. +/// +/// The devcontainer.json `name` is intentionally ignored: it is +/// free-form display text shared by every fork of a template. +pub(crate) fn derive_name_from_path( + host_workspace: &std::path::Path, + workspace_id: &str, +) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let basename = host_workspace + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("devcontainer"); + let mut h = DefaultHasher::new(); + host_workspace.hash(&mut h); + // Mix the workspace id in too so two different host registrations of + // the same path get distinct containers (rare but possible). + workspace_id.hash(&mut h); + let suffix = format!("{:08x}", (h.finish() as u32)); + format!("{basename}-{suffix}") +} + +/// Coerce an arbitrary devcontainer `name` (which is free-form, e.g. +/// `"JupyterLite Demo"`) into a string that container runtimes will +/// accept as an entity name. Apple's `container` CLI in particular +/// rejects spaces and many punctuation characters with `invalid entity +/// name`. We keep ASCII alphanumerics, dash and underscore; everything +/// else collapses to a single dash. Returns `None` if nothing usable +/// remains so the caller can fall back to a workspace-id based name. +pub(crate) fn sanitize_entity_name(input: &str) -> Option { + let mut out = String::with_capacity(input.len()); + let mut last_was_dash = false; + for c in input.chars() { + let keep = c.is_ascii_alphanumeric() || c == '-' || c == '_'; + if keep { + out.push(c); + last_was_dash = c == '-'; + } else if !last_was_dash && !out.is_empty() { + out.push('-'); + last_was_dash = true; + } + } + while out.ends_with('-') { + out.pop(); + } + // Container CLIs typically require the first character to be alpha- + // numeric; if we somehow ended up leading with `_` or `-`, drop them. + while out + .chars() + .next() + .is_some_and(|c| !c.is_ascii_alphanumeric()) + { + out.remove(0); + } + if out.is_empty() { None } else { Some(out) } +} + +pub fn parse_image_ref(s: &str) -> ImageRef { // Minimal parser: registry/repo:tag@digest. Sufficient for v1. let (rest, digest) = match s.split_once('@') { Some((r, d)) => (r, Some(d.to_string())), @@ -142,6 +293,71 @@ mod tests { assert_eq!(r.tag.as_deref(), Some("20-alpine")); } + #[test] + fn sanitize_entity_name_replaces_spaces_and_punctuation() { + assert_eq!( + sanitize_entity_name("JupyterLite Demo").as_deref(), + Some("JupyterLite-Demo") + ); + assert_eq!( + sanitize_entity_name(" hello / world! ").as_deref(), + Some("hello-world") + ); + assert_eq!( + sanitize_entity_name("already_ok-1").as_deref(), + Some("already_ok-1") + ); + assert_eq!(sanitize_entity_name(" "), None); + assert_eq!(sanitize_entity_name(""), None); + } + + #[test] + fn to_container_spec_derives_name_from_repo_path() { + // Free-form devcontainer.json `name` is intentionally ignored: + // it's display text shared by every fork of a template, so two + // repos with the same `name` would collide on the runtime's + // global container-name index. The container name is derived + // from the host repo path instead. + let parsed = ParsedDevContainer { + name: Some("JupyterLite Demo".into()), + image: Some("ubuntu:24.04".into()), + ..Default::default() + }; + let spec_a = to_container_spec( + &parsed, + parse_image_ref("ubuntu:24.04"), + "ws-1", + std::path::Path::new("/tmp/take-two"), + ); + let spec_b = to_container_spec( + &parsed, + parse_image_ref("ubuntu:24.04"), + "ws-2", + std::path::Path::new("/tmp/new-from-temp"), + ); + // Two different repos with the same devcontainer name must + // produce distinct container names. + assert_ne!(spec_a.name, spec_b.name); + assert!( + spec_a.name.starts_with("take-two-"), + "got {}", + spec_a.name + ); + assert!( + spec_b.name.starts_with("new-from-temp-"), + "got {}", + spec_b.name + ); + // Same path + workspace_id is deterministic so adoption works. + let again = to_container_spec( + &parsed, + parse_image_ref("ubuntu:24.04"), + "ws-1", + std::path::Path::new("/tmp/take-two"), + ); + assert_eq!(spec_a.name, again.name); + } + #[test] fn parses_registry_repo_tag_digest() { let r = parse_image_ref("ghcr.io/example/app:1.0@sha256:deadbeef"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9b9c77639..c6077f6c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,10 +28,13 @@ pub fn run() { let host_state = host::HostState::load().unwrap_or_default(); let runtime_registry = container::RuntimeRegistry::with_default_backends(); + let orchestrator = devcontainer::lifecycle::LifecycleOrchestrator::new(); tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .manage(host_state) .manage(runtime_registry) + .manage(orchestrator) .menu(host::menu::build_menu) .on_menu_event(host::menu::handle_menu_event) .invoke_handler(tauri::generate_handler![ @@ -40,6 +43,11 @@ pub fn run() { commands::workspace::remove_workspace, commands::runtime::list_runtimes, commands::runtime::select_runtime, + commands::runtime::list_containers, + commands::runtime::container_start_by_id, + commands::runtime::container_stop_by_id, + commands::runtime::container_remove_by_id, + commands::lifecycle::submit_parsed_devcontainer, commands::lifecycle::container_status, commands::lifecycle::container_up, commands::lifecycle::container_stop, diff --git a/src-tauri/tests/apple_containers_live.rs b/src-tauri/tests/apple_containers_live.rs new file mode 100644 index 000000000..9c7d9a39e --- /dev/null +++ b/src-tauri/tests/apple_containers_live.rs @@ -0,0 +1,429 @@ +//! Integration tests for the Apple `container` CLI adapter. +//! +//! These tests gate on the `apple-containers-live` cargo feature. They +//! exercise [`AppleContainersRuntime`] end-to-end by pointing it at a +//! tempdir-installed fake `container` binary that *strictly* mimics the +//! real CLI's argv contract — every quirk we've been bitten by lives in +//! the assertions below so a regression fails the build instead of only +//! showing up when the user clicks "Up": +//! +//! * `inspect` does NOT accept `--format` (always emits JSON). +//! * `inspect` / `list` emit `{ configuration: { id, image }, status }`. +//! * `exec` takes process argv positionally — a literal `--` is the +//! executable name and the CLI errors with "failed to find target +//! executable --". +//! * `create` of a duplicate name fails with +//! `exists: "container already exists: NAME"`. +//! * `start` returns before the container reaches `running`; `inspect` +//! must be polled. +//! * Apple's container exits as soon as its CMD does (no daemon), +//! so spec translation must inject a long-running command. +//! +//! When the real `container` binary is available on PATH and +//! `DEVCONTAINERS_LIVE_REAL=1` is set, additional tests at the bottom of +//! this file drive the actual CLI through a minimal lifecycle so we +//! also catch upstream behaviour drift. + +#![cfg(feature = "apple-containers-live")] + +use std::time::Duration; + +use devcontainers_app_lib::container::{ + apple_containers::AppleContainersRuntime, ContainerRuntime, ContainerRuntimeError, + ContainerSpec, ContainerState, ExecOptions, ImageRef, LogOptions, +}; + +fn exec_opts(cmd: &[&str]) -> ExecOptions { + ExecOptions { + command: cmd.iter().map(|s| s.to_string()).collect(), + workdir: None, + env: Default::default(), + user: None, + tty: false, + } +} + +// --------------------------------------------------------------------------- +// Strict fake CLI +// --------------------------------------------------------------------------- + +/// Materialise a fake `container` binary that mimics the real Apple CLI's +/// argv contract. State (created containers, running flag) lives in a +/// sibling state directory so successive invocations interact like the +/// real CLI does. +fn fake_container_script() -> (std::path::PathBuf, std::path::PathBuf) { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "devcontainers-fake-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + SEQ.fetch_add(1, Ordering::Relaxed), + )); + std::fs::create_dir_all(&dir).unwrap(); + let state = dir.join("state"); + std::fs::create_dir_all(&state).unwrap(); + let script = dir.join("container"); + let body = format!( + r#"#!/usr/bin/env bash +set -eu +state="{state}" + +die() {{ echo "Error: $*" 1>&2; exit 1; }} + +# Reject empty positional args; the real CLI errors with +# `notFound: "container with ID not found"`. +require_id() {{ + if [ -z "$1" ]; then + echo 'Error: internalError: "failed to '"$2"' container" (cause: "notFound: \"container with ID not found\"")' 1>&2 + exit 1 + fi +}} + +cmd="$1"; shift || true +case "$cmd" in + --version) echo "container 0.0.0-fake" ;; + + image) + sub="$1"; shift + case "$sub" in + pull) echo "pulled $1" ;; + *) die "unknown image subcommand: $sub" ;; + esac + ;; + + create) + name="" + while [ "$#" -gt 0 ]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --) shift; break ;; + *) shift ;; + esac + done + [ -n "$name" ] || die "missing --name" + if [ -e "$state/c-$name" ]; then + echo "Error: failed to create container (cause: \"exists: \"container already exists: $name\"\")" 1>&2 + exit 1 + fi + : > "$state/c-$name" + echo "$name" + ;; + + start) + id="${{1:-}}"; require_id "$id" start + [ -e "$state/c-$id" ] || die "container with ID $id not found" + # Real CLI returns *before* the container is fully running; we + # mimic that by writing a `pending` marker first then a `running` + # marker on the next inspect to force a poll. + if [ ! -e "$state/r-$id" ]; then + : > "$state/p-$id" + fi + ;; + + stop) + id="${{1:-}}"; require_id "$id" stop + [ -e "$state/c-$id" ] || die "container with ID $id not found" + rm -f "$state/r-$id" "$state/p-$id" + : > "$state/s-$id" + ;; + + delete) + force=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --force) force=1; shift ;; + *) break ;; + esac + done + id="${{1:-}}"; require_id "$id" delete + [ -e "$state/c-$id" ] || {{ + echo "Error: internalError: \"failed to delete container\" (cause: \"notFound: \"container with ID $id not found\"\")" 1>&2 + exit 1 + }} + rm -f "$state/c-$id" "$state/r-$id" "$state/p-$id" "$state/s-$id" + ;; + + inspect) + # The real CLI rejects `--format` outright with + # `Error: Unknown option '--format'` and exit code 64. + for a in "$@"; do + case "$a" in + --format|--format=*) + echo "Error: Unknown option '--format'" 1>&2 + exit 64 ;; + esac + done + id="${{1:-}}"; require_id "$id" inspect + [ -e "$state/c-$id" ] || {{ + echo "Error: container with ID $id not found" 1>&2 + exit 1 + }} + status="created" + if [ -e "$state/r-$id" ]; then status="running" + elif [ -e "$state/p-$id" ]; then + # First poll: not running yet. Second poll: promote to running. + rm -f "$state/p-$id"; : > "$state/r-$id" + status="created" + elif [ -e "$state/s-$id" ]; then status="stopped" + fi + # Identity nested under `configuration` like the real CLI. + printf '[{{"status":"%s","configuration":{{"id":"%s","image":{{"reference":"ubuntu:24.04"}}}}}}]\n' \ + "$status" "$id" + ;; + + list) + # Always emit JSON nested under `configuration`. + printf '[' + first=1 + for f in "$state"/c-*; do + [ -e "$f" ] || continue + id="${{f##*/c-}}" + status="created" + [ -e "$state/r-$id" ] && status="running" + [ -e "$state/p-$id" ] && status="created" + [ -e "$state/s-$id" ] && status="stopped" + if [ $first -eq 0 ]; then printf ','; fi + first=0 + printf '{{"status":"%s","configuration":{{"id":"%s","image":{{"reference":"ubuntu:24.04"}}}}}}' "$status" "$id" + done + printf ']\n' + ;; + + exec) + # Mirror Apple's argv parsing: options, then container-id, then + # process argv positionally. A literal `--` after the id is + # treated as the executable and the real CLI errors with + # `failed to find target executable --`. + while [ "$#" -gt 0 ]; do + case "$1" in + --workdir|--cwd|-w|--env|-e|--user|-u|--uid|--gid|--ulimit|--env-file) + shift 2 ;; + --tty|-t|--interactive|-i|--detach|-d|--debug) + shift ;; + *) break ;; + esac + done + id="${{1:-}}"; require_id "$id" exec; shift + [ -e "$state/c-$id" ] || die "container with ID $id not found" + [ -e "$state/r-$id" ] || die "container $id is not running" + if [ "${{1:-}}" = "--" ]; then + echo "Error: failed to find target executable --" 1>&2 + exit 1 + fi + [ "$#" -gt 0 ] || die "no command" + "$@" + ;; + + build) echo "Successfully built fake-image" ;; + + logs) + echo "line one" + echo "line two" 1>&2 + ;; + + *) die "unknown subcommand: $cmd" ;; +esac +"#, + state = state.display() + ); + std::fs::write(&script, body).unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perm = std::fs::metadata(&script).unwrap().permissions(); + perm.set_mode(0o755); + std::fs::set_permissions(&script, perm).unwrap(); + (script, state) +} + +fn ubuntu() -> ImageRef { + ImageRef { + registry: None, + repository: "ubuntu".into(), + tag: Some("24.04".into()), + digest: None, + } +} + +fn spec(name: &str) -> ContainerSpec { + ContainerSpec { + name: name.into(), + image: ubuntu(), + // Long-running command, matching what `to_container_spec` + // injects for Apple containers. + command: Some(vec![ + "/bin/sh".into(), + "-c".into(), + "while sleep 2147483647; do :; done".into(), + ]), + workdir: None, + env: Default::default(), + mounts: vec![], + ports: vec![], + user: None, + privileged: false, + } +} + +// --------------------------------------------------------------------------- +// End-to-end smoke +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn end_to_end_against_fake_cli() { + let (script, _state) = fake_container_script(); + let rt = AppleContainersRuntime::with_binary(script.display().to_string()); + + rt.pull(&ubuntu()).await.unwrap(); + let cid = rt.create(&spec("dev")).await.unwrap(); + assert_eq!(cid, "dev"); + + // start polls inspect; the fake reports "created" once before + // promoting to running, so this verifies the poll loop too. + rt.start(&cid).await.unwrap(); + + let status = rt.inspect(&cid).await.unwrap(); + assert_eq!(status.container_id, "dev"); + assert_eq!(status.state, ContainerState::Running); + assert_eq!(status.image_ref.as_deref(), Some("ubuntu:24.04")); + + let list = rt.list().await.unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].container_id, "dev"); + assert_eq!(list[0].state, ContainerState::Running); + assert_eq!(list[0].image_ref.as_deref(), Some("ubuntu:24.04")); + + let mut rx = rt + .logs( + &cid, + &LogOptions { + follow: false, + tail: None, + }, + ) + .await + .unwrap(); + let mut got = Vec::new(); + while let Some(chunk) = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .ok() + .flatten() + { + got.push(chunk.line); + } + got.sort(); + assert_eq!(got, vec!["line one".to_string(), "line two".to_string()]); + + rt.stop(&cid).await.unwrap(); + rt.remove(&cid, true).await.unwrap(); +} + +// --------------------------------------------------------------------------- +// Regression tests for previously-shipped bugs +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn inspect_does_not_pass_format_flag() { + // Real CLI rejects `--format` with exit code 64; if the adapter ever + // adds it back, the strict fake fails inspect and this test fires. + let (script, _state) = fake_container_script(); + let rt = AppleContainersRuntime::with_binary(script.display().to_string()); + rt.create(&spec("dev")).await.unwrap(); + rt.start("dev").await.unwrap(); + let status = rt.inspect("dev").await.expect("inspect must not pass --format"); + assert_eq!(status.state, ContainerState::Running); +} + +#[tokio::test] +async fn exec_uses_no_dash_dash_separator() { + // Real CLI treats a literal `--` as the executable name. The fake + // mirrors that: if the adapter regresses, exec fails with + // "failed to find target executable --". + let (script, _state) = fake_container_script(); + let rt = AppleContainersRuntime::with_binary(script.display().to_string()); + rt.create(&spec("dev")).await.unwrap(); + rt.start("dev").await.unwrap(); + let result = rt + .exec("dev", &exec_opts(&["/bin/sh", "-c", "echo hello"])) + .await + .expect("exec must not pass `--`"); + assert_eq!(result.exit_code, 0); + let stdout = String::from_utf8_lossy(&result.stdout); + assert!(stdout.contains("hello"), "stdout={stdout:?}"); +} + +#[tokio::test] +async fn list_parses_nested_configuration_id() { + // `container list --format json` emits identity under `configuration`. + // Without nested-id support, container_id would come back empty and + // any subsequent stop/remove would target "". The strict fake only + // emits the nested shape, so this is an end-to-end check. + let (script, _state) = fake_container_script(); + let rt = AppleContainersRuntime::with_binary(script.display().to_string()); + rt.create(&spec("alpha")).await.unwrap(); + rt.create(&spec("beta")).await.unwrap(); + let list = rt.list().await.unwrap(); + let ids: Vec<_> = list.iter().map(|s| s.container_id.clone()).collect(); + assert!(ids.iter().all(|id| !id.is_empty())); + assert!(ids.contains(&"alpha".to_string())); + assert!(ids.contains(&"beta".to_string())); +} + +#[tokio::test] +async fn create_with_duplicate_name_surfaces_already_exists() { + let (script, _state) = fake_container_script(); + let rt = AppleContainersRuntime::with_binary(script.display().to_string()); + rt.create(&spec("dup")).await.unwrap(); + let err = rt + .create(&spec("dup")) + .await + .expect_err("second create with same name must error"); + let msg = format!("{err}"); + assert!( + msg.to_ascii_lowercase().contains("already exists"), + "expected 'already exists' in error, got: {msg}", + ); +} + +#[tokio::test] +async fn empty_container_id_is_rejected_before_shelling_out() { + // Avoids the embarrassing + // `container delete --force ` exited with status 1: "container with ID not found" + // we used to surface in the UI when an empty cid leaked through. + let (script, _state) = fake_container_script(); + let rt = AppleContainersRuntime::with_binary(script.display().to_string()); + for r in [ + rt.start("").await, + rt.stop("").await, + rt.remove("", true).await, + rt.inspect("").await.map(|_| ()), + ] { + let err = r.expect_err("empty container id must be rejected"); + match err { + ContainerRuntimeError::Backend(msg) => { + assert!( + msg.to_ascii_lowercase().contains("missing container id"), + "got: {msg}", + ); + } + other => panic!("unexpected error: {other:?}"), + } + } +} + +#[tokio::test] +async fn start_polls_inspect_until_running() { + // The fake's first `inspect` after `start` reports "created"; the + // adapter must poll until "running" or the next call (typically an + // `exec` running postCreateCommand) races into a stopped container. + let (script, _state) = fake_container_script(); + let rt = AppleContainersRuntime::with_binary(script.display().to_string()); + rt.create(&spec("racy")).await.unwrap(); + rt.start("racy").await.unwrap(); + let status = rt.inspect("racy").await.unwrap(); + assert_eq!(status.state, ContainerState::Running); +} + +// Real-CLI integration tests live in `tests/apple_containers_real.rs`. diff --git a/src-tauri/tests/apple_containers_real.rs b/src-tauri/tests/apple_containers_real.rs new file mode 100644 index 000000000..234782b91 --- /dev/null +++ b/src-tauri/tests/apple_containers_real.rs @@ -0,0 +1,268 @@ +//! Integration tests against the real Apple `container` CLI. +//! +//! Gated on the `apple-containers-live` cargo feature *and* a runtime probe +//! that runs `container --version`. If the binary isn't on PATH (e.g. on +//! Linux CI), each test prints a skip message and returns Ok — so these +//! tests are safe to leave enabled in `cargo test --all-targets` while +//! still providing real coverage on macOS 26+ developer machines. +//! +//! Each test uses a uniquely-named container and an RAII cleanup guard so +//! a panic mid-test doesn't leak containers. +//! +//! Run on macOS 26 with the container service active: +//! +//! ```sh +//! cargo test --features apple-containers-live --test apple_containers_real +//! ``` + +#![cfg(feature = "apple-containers-live")] + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use devcontainers_app_lib::container::{ + apple_containers::AppleContainersRuntime, ContainerRuntime, ContainerSpec, ContainerState, + ExecOptions, ImageRef, +}; + +// --------------------------------------------------------------------------- +// Skip logic + helpers +// --------------------------------------------------------------------------- + +/// Returns true when `container --version` succeeds. Used to gracefully +/// skip on hosts without the Apple CLI (Linux CI, older macOS, etc). +fn container_cli_available() -> bool { + std::process::Command::new("container") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Convenience macro: emit a skip line and return early if the real CLI +/// isn't available. Keeps each test's happy path readable. +macro_rules! require_cli { + () => { + if !container_cli_available() { + eprintln!( + "skipping {}: real `container` CLI not available on PATH", + stdfn!() + ); + return; + } + }; +} + +macro_rules! stdfn { + () => {{ + fn f() {} + fn type_name_of(_: T) -> &'static str { + std::any::type_name::() + } + let n = type_name_of(f); + &n[..n.len() - 3] + }}; +} + +/// Process-unique sequence to disambiguate container names within a single +/// `cargo test` run. Combined with a wall-clock seed per process. +static SEQ: AtomicU64 = AtomicU64::new(0); + +fn unique_name(tag: &str) -> String { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let n = SEQ.fetch_add(1, Ordering::Relaxed); + format!("dcc-it-{tag}-{seed}-{n}") +} + +/// RAII guard that force-deletes the container when dropped. Run via the +/// real CLI so we still clean up even if our adapter is the source of the +/// bug under test. +struct Cleanup(String); +impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::process::Command::new("container") + .args(["delete", "--force", &self.0]) + .output(); + } +} + +fn alpine() -> ImageRef { + ImageRef { + registry: None, + repository: "library/alpine".into(), + tag: Some("3.19".into()), + digest: None, + } +} + +/// Spec matching what `to_container_spec` emits for Apple containers: a +/// long-running keep-alive command so `exec` has time to land. +fn keepalive_spec(name: &str) -> ContainerSpec { + ContainerSpec { + name: name.into(), + image: alpine(), + command: Some(vec![ + "/bin/sh".into(), + "-c".into(), + "while sleep 2147483647; do :; done".into(), + ]), + workdir: None, + env: Default::default(), + mounts: vec![], + ports: vec![], + user: None, + privileged: false, + } +} + +fn exec_opts(cmd: &[&str]) -> ExecOptions { + ExecOptions { + command: cmd.iter().map(|s| s.to_string()).collect(), + workdir: None, + env: Default::default(), + user: None, + tty: false, + } +} + +fn rt() -> AppleContainersRuntime { + AppleContainersRuntime::with_binary("container") +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Full lifecycle: pull → create → start (must poll until running) → +/// inspect (must NOT pass `--format`, must read nested +/// `configuration.id`/`image`) → list (same) → exec (must NOT pass `--`) → +/// stop → remove. +/// +/// This single test covers most of the regressions we shipped: +/// * inspect `--format` rejection. +/// * exec `--` separator rejection. +/// * list/inspect identity nested under `configuration`. +/// * start returning before `running`. +/// * overrideCommand keep-alive (otherwise the container exits before +/// exec lands). +#[tokio::test] +async fn real_full_lifecycle_round_trips() { + require_cli!(); + let rt = rt(); + let name = unique_name("life"); + let _cleanup = Cleanup(name.clone()); + + // pull is idempotent; alpine:3.19 is small and cached after first run. + rt.pull(&alpine()).await.expect("pull alpine"); + + let cid = rt.create(&keepalive_spec(&name)).await.expect("create"); + assert!(!cid.is_empty(), "create must return a non-empty id"); + + rt.start(&cid).await.expect("start"); + + // inspect: container_id and image_ref must come back populated, which + // only works if we read `configuration.id` / `configuration.image`. + let status = rt.inspect(&cid).await.expect("inspect"); + assert_eq!(status.state, ContainerState::Running, "must be running"); + assert!( + !status.container_id.is_empty(), + "container_id must be populated (regression: nested configuration)", + ); + assert!( + status.image_ref.as_deref().is_some_and(|s| !s.is_empty()), + "image_ref must be populated (regression: nested configuration), got {:?}", + status.image_ref, + ); + + // list: every entry must have a non-empty container_id; ours must be + // present. + let listed = rt.list().await.expect("list"); + assert!( + listed.iter().all(|s| !s.container_id.is_empty()), + "every list entry must have a populated container_id", + ); + assert!( + listed.iter().any(|s| s.container_id == cid), + "freshly-created container must appear in list", + ); + + // exec: regression for `failed to find target executable --`. + let result = rt + .exec(&cid, &exec_opts(&["/bin/sh", "-c", "echo hello-real"])) + .await + .expect("exec"); + assert_eq!(result.exit_code, 0, "exec exit code"); + let stdout = String::from_utf8_lossy(&result.stdout); + assert!(stdout.contains("hello-real"), "stdout: {stdout}"); + + rt.stop(&cid).await.expect("stop"); + rt.remove(&cid, true).await.expect("remove"); +} + +/// Creating a container with a name that already exists must return an +/// error that the lifecycle orchestrator can recognise as "already +/// exists" so it can adopt the existing container. +#[tokio::test] +async fn real_create_duplicate_name_errors() { + require_cli!(); + let rt = rt(); + let name = unique_name("dup"); + let _cleanup = Cleanup(name.clone()); + rt.pull(&alpine()).await.expect("pull alpine"); + + let cid = rt.create(&keepalive_spec(&name)).await.expect("first create"); + assert!(!cid.is_empty()); + + let err = rt + .create(&keepalive_spec(&name)) + .await + .expect_err("second create with same name must fail"); + let msg = format!("{err}").to_ascii_lowercase(); + assert!( + msg.contains("already exists") || msg.contains("exists"), + "expected 'already exists' in error, got: {msg}", + ); +} + +/// Inspecting a non-existent container must fail with a recognisable +/// "not found" error so adoption logic can branch on it. +#[tokio::test] +async fn real_inspect_unknown_id_errors_not_found() { + require_cli!(); + let rt = rt(); + let bogus = unique_name("nope"); + let err = rt + .inspect(&bogus) + .await + .expect_err("inspect of unknown id must fail"); + let msg = format!("{err}").to_ascii_lowercase(); + assert!( + msg.contains("not found") || msg.contains("no such"), + "expected 'not found' in error, got: {msg}", + ); +} + +/// An empty container id must be rejected by our adapter *before* it +/// shells out; otherwise the user sees a useless +/// `container with ID not found` message. +#[tokio::test] +async fn real_empty_id_rejected_before_shelling_out() { + require_cli!(); + let rt = rt(); + for r in [ + rt.start("").await, + rt.stop("").await, + rt.remove("", true).await, + rt.inspect("").await.map(|_| ()), + ] { + let err = r.expect_err("empty id must be rejected"); + let msg = format!("{err}").to_ascii_lowercase(); + assert!( + msg.contains("missing container id"), + "expected local guard, got: {msg}", + ); + } +}